@natoe/colab 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -66,6 +66,13 @@ function toCamelKey(key) {
66
66
  var CollabSocket = class {
67
67
  constructor() {
68
68
  this.socket = null;
69
+ /**
70
+ * Conversation channels keyed by id. Each entry is reference-counted so
71
+ * multiple surfaces (e.g. inline chat + expanded panel mounted at once
72
+ * for the same conversation) can coexist without one's `leaveConversation`
73
+ * tearing the channel out from under the other. See `joinConversation`
74
+ * and the returned `ChannelSubscription.release`.
75
+ */
69
76
  this.channels = /* @__PURE__ */ new Map();
70
77
  this.presences = /* @__PURE__ */ new Map();
71
78
  this.userChannel = null;
@@ -136,59 +143,85 @@ var CollabSocket = class {
136
143
  onUnreadCountUpdate(callback) {
137
144
  this.onUnreadUpdate = callback;
138
145
  }
139
- /** Join a conversation channel and subscribe to events */
146
+ /**
147
+ * Join a conversation channel and subscribe to events.
148
+ *
149
+ * Reference-counted: multiple callers can join the same conversation
150
+ * (e.g. inline preview + expanded panel mounted side-by-side). Each
151
+ * call binds its own listeners and gets back a `ChannelSubscription`.
152
+ * The underlying channel only `.leave()`s the server when the LAST
153
+ * subscriber calls `release()`.
154
+ */
140
155
  joinConversation(conversationId, callbacks) {
141
156
  if (!this.socket) return null;
142
- if (this.channels.has(conversationId)) {
143
- return this.channels.get(conversationId);
157
+ let entry = this.channels.get(conversationId);
158
+ if (!entry) {
159
+ const channel2 = this.socket.channel(`conversation:${conversationId}`, {});
160
+ channel2.join().receive("ok", () => {
161
+ }).receive("error", (reason) => {
162
+ this.config?.onError?.({
163
+ code: "CHANNEL_JOIN_ERROR",
164
+ message: `Failed to join conversation ${conversationId}`,
165
+ details: reason
166
+ });
167
+ });
168
+ entry = { channel: channel2, subscribers: 0, presence: null };
169
+ this.channels.set(conversationId, entry);
144
170
  }
145
- const channel = this.socket.channel(`conversation:${conversationId}`, {});
171
+ const channel = entry.channel;
172
+ const refs = [];
173
+ const bind = (event, fn) => {
174
+ const ref = channel.on(event, fn);
175
+ refs.push({ event, ref });
176
+ };
146
177
  if (callbacks.onMessage) {
147
- channel.on(EVENTS.MESSAGE_NEW, (payload) => {
178
+ bind(EVENTS.MESSAGE_NEW, (payload) => {
148
179
  callbacks.onMessage(snakeToCamel(payload));
149
180
  });
150
181
  }
151
182
  if (callbacks.onTyping) {
152
- channel.on(EVENTS.USER_TYPING, (payload) => {
183
+ bind(EVENTS.USER_TYPING, (payload) => {
153
184
  callbacks.onTyping(snakeToCamel(payload));
154
185
  });
155
186
  }
156
187
  if (callbacks.onUserJoined) {
157
- channel.on(EVENTS.USER_JOINED, (payload) => {
188
+ bind(EVENTS.USER_JOINED, (payload) => {
158
189
  callbacks.onUserJoined(snakeToCamel(payload));
159
190
  });
160
191
  }
161
192
  if (callbacks.onUserLeft) {
162
- channel.on(EVENTS.USER_LEFT, (payload) => {
193
+ bind(EVENTS.USER_LEFT, (payload) => {
163
194
  callbacks.onUserLeft(snakeToCamel(payload));
164
195
  });
165
196
  }
166
197
  if (callbacks.onChannelUpdated) {
167
- channel.on(EVENTS.CHANNEL_UPDATED, (payload) => {
198
+ bind(EVENTS.CHANNEL_UPDATED, (payload) => {
168
199
  callbacks.onChannelUpdated(snakeToCamel(payload));
169
200
  });
170
201
  }
171
202
  if (callbacks.onChannelDeleted) {
172
- channel.on(EVENTS.CHANNEL_DELETED, () => {
203
+ bind(EVENTS.CHANNEL_DELETED, () => {
173
204
  callbacks.onChannelDeleted();
174
205
  });
175
206
  }
176
207
  if (callbacks.onMessageRead) {
177
- channel.on(EVENTS.MESSAGE_READ, (payload) => {
178
- callbacks.onMessageRead(snakeToCamel(payload));
208
+ bind(EVENTS.MESSAGE_READ, (payload) => {
209
+ callbacks.onMessageRead(
210
+ snakeToCamel(payload)
211
+ );
179
212
  });
180
213
  }
181
214
  if (callbacks.onMessagePinned) {
182
- channel.on(EVENTS.MESSAGE_PINNED, (payload) => {
215
+ bind(EVENTS.MESSAGE_PINNED, (payload) => {
183
216
  callbacks.onMessagePinned(snakeToCamel(payload));
184
217
  });
185
218
  }
186
219
  if (callbacks.onMessageUnpinned) {
187
- channel.on(EVENTS.MESSAGE_UNPINNED, (payload) => {
220
+ bind(EVENTS.MESSAGE_UNPINNED, (payload) => {
188
221
  callbacks.onMessageUnpinned(snakeToCamel(payload));
189
222
  });
190
223
  }
191
- if (callbacks.onPresence) {
224
+ if (callbacks.onPresence && !entry.presence) {
192
225
  const presence = new phoenix.Presence(channel);
193
226
  presence.onSync(() => {
194
227
  const online = {};
@@ -197,27 +230,45 @@ var CollabSocket = class {
197
230
  });
198
231
  callbacks.onPresence(online);
199
232
  });
233
+ entry.presence = presence;
200
234
  this.presences.set(conversationId, presence);
201
235
  }
202
- channel.join().receive("ok", () => {
203
- }).receive("error", (reason) => {
204
- this.config?.onError?.({
205
- code: "CHANNEL_JOIN_ERROR",
206
- message: `Failed to join conversation ${conversationId}`,
207
- details: reason
208
- });
209
- });
210
- this.channels.set(conversationId, channel);
211
- return channel;
236
+ entry.subscribers += 1;
237
+ let released = false;
238
+ const release = () => {
239
+ if (released) return;
240
+ released = true;
241
+ const current = this.channels.get(conversationId);
242
+ if (!current) return;
243
+ for (const { event, ref } of refs) {
244
+ current.channel.off(event, ref);
245
+ }
246
+ current.subscribers -= 1;
247
+ if (current.subscribers <= 0) {
248
+ current.channel.leave();
249
+ this.channels.delete(conversationId);
250
+ this.presences.delete(conversationId);
251
+ }
252
+ };
253
+ return { channel, release };
212
254
  }
213
- /** Leave a conversation channel */
214
- leaveConversation(conversationId) {
215
- const channel = this.channels.get(conversationId);
216
- if (channel) {
217
- channel.leave();
218
- this.channels.delete(conversationId);
219
- this.presences.delete(conversationId);
220
- }
255
+ /**
256
+ * @deprecated Use the `release()` method returned by `joinConversation()`.
257
+ * Kept as a no-op so older callers don't throw — but it cannot identify
258
+ * which subscriber should leave, so it silently does nothing. Any code
259
+ * still calling this will leak listeners and prevent the channel from
260
+ * ever being torn down. Migrate to the subscription handle.
261
+ */
262
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
263
+ leaveConversation(_conversationId) {
264
+ }
265
+ /**
266
+ * Look up the underlying Phoenix Channel for a conversation, if any
267
+ * subscriber is still holding it. All send/push paths go through this
268
+ * helper so the refcounted entry shape is contained to joinConversation.
269
+ */
270
+ getChannel(conversationId) {
271
+ return this.channels.get(conversationId)?.channel ?? null;
221
272
  }
222
273
  /** Send a message to a conversation.
223
274
  *
@@ -229,7 +280,7 @@ var CollabSocket = class {
229
280
  */
230
281
  sendMessage(conversationId, payload) {
231
282
  return new Promise((resolve, reject) => {
232
- const channel = this.channels.get(conversationId);
283
+ const channel = this.getChannel(conversationId);
233
284
  if (!channel) {
234
285
  reject({ code: "NOT_JOINED", message: "Not joined to this conversation" });
235
286
  return;
@@ -252,7 +303,7 @@ var CollabSocket = class {
252
303
  }
253
304
  /** Broadcast typing indicator */
254
305
  sendTyping(conversationId, isTyping) {
255
- const channel = this.channels.get(conversationId);
306
+ const channel = this.getChannel(conversationId);
256
307
  channel?.push(EVENTS.USER_TYPING, {
257
308
  userId: this.config?.userId,
258
309
  userName: this.config?.userName,
@@ -261,7 +312,7 @@ var CollabSocket = class {
261
312
  }
262
313
  /** Mark messages as read */
263
314
  markAsRead(conversationId, messageId) {
264
- const channel = this.channels.get(conversationId);
315
+ const channel = this.getChannel(conversationId);
265
316
  channel?.push(EVENTS.MESSAGE_READ, {
266
317
  messageId,
267
318
  userId: this.config?.userId
@@ -295,7 +346,7 @@ var CollabSocket = class {
295
346
  /** Generic push with promise wrapper */
296
347
  channelPush(conversationId, event, payload) {
297
348
  return new Promise((resolve, reject) => {
298
- const channel = this.channels.get(conversationId);
349
+ const channel = this.getChannel(conversationId);
299
350
  if (!channel) {
300
351
  reject({ code: "NOT_JOINED", message: "Not joined to this conversation" });
301
352
  return;
@@ -312,7 +363,7 @@ var CollabSocket = class {
312
363
  }
313
364
  /** Disconnect socket and leave all channels */
314
365
  disconnect() {
315
- this.channels.forEach((channel) => channel.leave());
366
+ this.channels.forEach((entry) => entry.channel.leave());
316
367
  this.channels.clear();
317
368
  this.presences.clear();
318
369
  this.userChannel?.leave();
@@ -321,6 +372,203 @@ var CollabSocket = class {
321
372
  this.config = null;
322
373
  }
323
374
  };
375
+
376
+ // src/core/theme.ts
377
+ var FONT_SIZE = {
378
+ xs: "12px",
379
+ sm: "13px",
380
+ md: "15px",
381
+ lg: "17px",
382
+ xxl: "28px"
383
+ };
384
+ var FONT_WEIGHT = {
385
+ regular: 400,
386
+ medium: 500,
387
+ semibold: 600,
388
+ bold: 700
389
+ };
390
+ var LINE_HEIGHT = {
391
+ tight: 1.3,
392
+ normal: 1.45};
393
+ var SPACE = {
394
+ S1: "4px",
395
+ S2: "8px",
396
+ S3: "12px",
397
+ S4: "16px",
398
+ S5: "20px",
399
+ S6: "24px",
400
+ S12: "48px"
401
+ };
402
+ var RADIUS = {
403
+ sm: "4px",
404
+ md: "8px",
405
+ lg: "12px",
406
+ xl: "16px",
407
+ pill: "9999px",
408
+ full: "50%"
409
+ };
410
+ var THEME_VAR = {
411
+ primary: "--natoe-colab-primary",
412
+ primaryHover: "--natoe-colab-primary-hover",
413
+ primaryBg: "--natoe-colab-primary-bg",
414
+ primaryFg: "--natoe-colab-primary-fg",
415
+ success: "--natoe-colab-success",
416
+ warning: "--natoe-colab-warning",
417
+ danger: "--natoe-colab-danger",
418
+ dangerBg: "--natoe-colab-danger-bg",
419
+ dangerBorder: "--natoe-colab-danger-border",
420
+ dangerFg: "--natoe-colab-danger-fg",
421
+ fontStack: "--natoe-colab-font-stack",
422
+ // Neutral surface palette — promoted to CSS vars so consumers (e.g.
423
+ // CollabPanel themeMode='dark') can flip the whole grayscale at a subtree
424
+ // level without re-themeing every component.
425
+ white: "--natoe-colab-white",
426
+ neutral50: "--natoe-colab-neutral-50",
427
+ neutral100: "--natoe-colab-neutral-100",
428
+ neutral200: "--natoe-colab-neutral-200",
429
+ neutral300: "--natoe-colab-neutral-300",
430
+ neutral400: "--natoe-colab-neutral-400",
431
+ neutral500: "--natoe-colab-neutral-500",
432
+ neutral600: "--natoe-colab-neutral-600",
433
+ neutral700: "--natoe-colab-neutral-700",
434
+ neutral800: "--natoe-colab-neutral-800",
435
+ neutral900: "--natoe-colab-neutral-900"
436
+ };
437
+ var THEME_DEFAULTS = {
438
+ primary: "#2563eb",
439
+ primaryHover: "#1d4ed8",
440
+ primaryBg: "#dbeafe",
441
+ primaryFg: "#1d4ed8",
442
+ success: "#059669",
443
+ warning: "#d97706",
444
+ danger: "#dc2626",
445
+ dangerBg: "#fef2f2",
446
+ dangerBorder: "#fecaca",
447
+ dangerFg: "#b91c1c",
448
+ fontStack: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
449
+ // Neutral grayscale — these defaults are the same hex literals the
450
+ // package shipped with; they're now overridable per-subtree.
451
+ white: "#ffffff",
452
+ neutral50: "#f9fafb",
453
+ neutral100: "#f3f4f6",
454
+ neutral200: "#e5e7eb",
455
+ neutral300: "#d1d5db",
456
+ neutral400: "#9ca3af",
457
+ neutral500: "#6b7280",
458
+ neutral600: "#4b5563",
459
+ neutral700: "#374151",
460
+ neutral800: "#1f2937",
461
+ neutral900: "#111827"
462
+ };
463
+ var cssVar = (name, fallback) => `var(${name}, ${fallback})`;
464
+ var COLOR = {
465
+ // Brand — themeable
466
+ primary: cssVar(THEME_VAR.primary, THEME_DEFAULTS.primary),
467
+ primaryHover: cssVar(THEME_VAR.primaryHover, THEME_DEFAULTS.primaryHover),
468
+ /** Tinted surface for chips/cards on brand-coloured states. */
469
+ primaryBg: cssVar(THEME_VAR.primaryBg, THEME_DEFAULTS.primaryBg),
470
+ primaryFg: cssVar(THEME_VAR.primaryFg, THEME_DEFAULTS.primaryFg),
471
+ // Neutral grayscale — themeable via CSS variables. Light-mode defaults
472
+ // taken from Tailwind's zinc-leaning slate to match the package's
473
+ // existing tonal balance; a host can override the entire palette by
474
+ // setting --natoe-colab-* values on any ancestor element (used by
475
+ // CollabPanel themeMode='dark' for the viewer's left-panel surface).
476
+ white: cssVar(THEME_VAR.white, THEME_DEFAULTS.white),
477
+ neutral50: cssVar(THEME_VAR.neutral50, THEME_DEFAULTS.neutral50),
478
+ neutral100: cssVar(THEME_VAR.neutral100, THEME_DEFAULTS.neutral100),
479
+ neutral200: cssVar(THEME_VAR.neutral200, THEME_DEFAULTS.neutral200),
480
+ neutral300: cssVar(THEME_VAR.neutral300, THEME_DEFAULTS.neutral300),
481
+ neutral400: cssVar(THEME_VAR.neutral400, THEME_DEFAULTS.neutral400),
482
+ neutral500: cssVar(THEME_VAR.neutral500, THEME_DEFAULTS.neutral500),
483
+ neutral600: cssVar(THEME_VAR.neutral600, THEME_DEFAULTS.neutral600),
484
+ neutral700: cssVar(THEME_VAR.neutral700, THEME_DEFAULTS.neutral700),
485
+ neutral800: cssVar(THEME_VAR.neutral800, THEME_DEFAULTS.neutral800),
486
+ neutral900: cssVar(THEME_VAR.neutral900, THEME_DEFAULTS.neutral900),
487
+ slate800: "#1e293b",
488
+ // Semantic — themeable
489
+ success: cssVar(THEME_VAR.success, THEME_DEFAULTS.success),
490
+ warning: cssVar(THEME_VAR.warning, THEME_DEFAULTS.warning),
491
+ danger: cssVar(THEME_VAR.danger, THEME_DEFAULTS.danger),
492
+ dangerBg: cssVar(THEME_VAR.dangerBg, THEME_DEFAULTS.dangerBg),
493
+ dangerBorder: cssVar(THEME_VAR.dangerBorder, THEME_DEFAULTS.dangerBorder),
494
+ dangerFg: cssVar(THEME_VAR.dangerFg, THEME_DEFAULTS.dangerFg)
495
+ };
496
+ var SIZE = {
497
+ /** Default interactive height (text buttons, list rows). */
498
+ control: "40px",
499
+ /** Square icon-only buttons inside chrome (popup title, menu trigger). */
500
+ controlIcon: "36px"};
501
+ var SHADOW = {
502
+ /** Cards / floating popovers (message-actions menu). */
503
+ popover: "0 6px 18px rgba(0, 0, 0, 0.12)",
504
+ toast: "0 4px 12px rgba(0, 0, 0, 0.18)"
505
+ };
506
+ var Z_INDEX = {
507
+ /** Sticky day divider — above bubbles, below interactive popovers. */
508
+ sticky: 1,
509
+ /** Toasts inside a panel. */
510
+ toast: 20,
511
+ /** Floating CollabPopup dialog. */
512
+ dialog: 9999,
513
+ /** Portaled message-actions menu — must sit above the dialog. */
514
+ menu: 1e4
515
+ };
516
+
517
+ // src/core/styles.ts
518
+ var FONT_STACK = `var(${THEME_VAR.fontStack}, ${THEME_DEFAULTS.fontStack})`;
519
+ var ROOT_CLASS = "natoe-colab-root";
520
+ var GLOBAL_STYLE_ID = "natoe-colab-global-styles";
521
+ var ROOT_DEFAULTS_BLOCK = Object.keys(THEME_DEFAULTS).map((key) => ` ${THEME_VAR[key]}: ${THEME_DEFAULTS[key]};`).join("\n");
522
+ var GLOBAL_CSS = `
523
+ :root {
524
+ ${ROOT_DEFAULTS_BLOCK}
525
+ }
526
+
527
+ @keyframes natoe-colab-spin { to { transform: rotate(360deg); } }
528
+
529
+ @keyframes natoe-colab-message-in {
530
+ from { opacity: 0; transform: translateY(4px); }
531
+ to { opacity: 1; transform: translateY(0); }
532
+ }
533
+
534
+ /* High-contrast adjustments \u2014 older users on OS-level high-contrast mode
535
+ get heavier borders and stronger text without losing the package look. */
536
+ @media (prefers-contrast: more) {
537
+ .${ROOT_CLASS} {
538
+ color: #000000;
539
+ }
540
+ .${ROOT_CLASS} button {
541
+ outline: 1px solid currentColor;
542
+ }
543
+ }
544
+
545
+ /* Honour reduced-motion preferences by killing entry animations. */
546
+ @media (prefers-reduced-motion: reduce) {
547
+ .${ROOT_CLASS} [data-natoe-message-bubble] {
548
+ animation: none !important;
549
+ }
550
+ }
551
+ `;
552
+ function ensureGlobalStyles() {
553
+ if (typeof document === "undefined") return;
554
+ if (document.getElementById(GLOBAL_STYLE_ID)) return;
555
+ const style = document.createElement("style");
556
+ style.id = GLOBAL_STYLE_ID;
557
+ style.textContent = GLOBAL_CSS;
558
+ document.head.appendChild(style);
559
+ }
560
+ function applyThemeOverrides(theme) {
561
+ if (typeof document === "undefined") return;
562
+ const root = document.documentElement;
563
+ Object.keys(THEME_VAR).forEach((key) => {
564
+ const value = theme?.[key];
565
+ if (value) {
566
+ root.style.setProperty(THEME_VAR[key], value);
567
+ } else {
568
+ root.style.removeProperty(THEME_VAR[key]);
569
+ }
570
+ });
571
+ }
324
572
  var CollabContext = React4.createContext(null);
325
573
  function useCollab() {
326
574
  const context = React4.useContext(CollabContext);
@@ -339,6 +587,9 @@ function CollabProvider({ config, apiBaseUrl, children }) {
339
587
  const pendingResolvers = React4.useRef(/* @__PURE__ */ new Map());
340
588
  const previewCache = React4.useRef(/* @__PURE__ */ new Map());
341
589
  const batchScheduled = React4.useRef(false);
590
+ React4.useEffect(() => {
591
+ applyThemeOverrides(config.theme);
592
+ }, [config.theme]);
342
593
  React4.useEffect(() => {
343
594
  if (typeof window === "undefined") return;
344
595
  let s = socket;
@@ -554,6 +805,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
554
805
  config,
555
806
  apiBaseUrl,
556
807
  totalUnread,
808
+ unreadCounts,
557
809
  requestPreview,
558
810
  invalidatePreview,
559
811
  fetchMessages,
@@ -610,6 +862,7 @@ function useConversation({
610
862
  const [isConnected, setIsConnected] = React4.useState(false);
611
863
  const [replyTo, setReplyTo] = React4.useState(null);
612
864
  const joinedConversationId = React4.useRef(null);
865
+ const channelSubscription = React4.useRef(null);
613
866
  const typingTimers = React4.useRef(/* @__PURE__ */ new Map());
614
867
  const ensureConversationInFlight = React4.useRef(null);
615
868
  const markedReadIdsRef = React4.useRef(/* @__PURE__ */ new Set());
@@ -623,7 +876,9 @@ function useConversation({
623
876
  const joinChannel = React4.useCallback(
624
877
  (conv) => {
625
878
  if (joinedConversationId.current === conv.id) return;
626
- socket.joinConversation(conv.id, {
879
+ channelSubscription.current?.release();
880
+ channelSubscription.current = null;
881
+ const subscription = socket.joinConversation(conv.id, {
627
882
  // Dedupe by id so an optimistic message (added client-side on send)
628
883
  // doesn't double up when the server's broadcast arrives.
629
884
  onMessage: (msg) => setMessages((prev) => {
@@ -673,6 +928,8 @@ function useConversation({
673
928
  onChannelDeleted: () => {
674
929
  setConversation(null);
675
930
  setMessages([]);
931
+ channelSubscription.current?.release();
932
+ channelSubscription.current = null;
676
933
  joinedConversationId.current = null;
677
934
  setIsConnected(false);
678
935
  },
@@ -698,6 +955,7 @@ function useConversation({
698
955
  );
699
956
  }
700
957
  });
958
+ channelSubscription.current = subscription;
701
959
  joinedConversationId.current = conv.id;
702
960
  setIsConnected(true);
703
961
  },
@@ -731,26 +989,22 @@ function useConversation({
731
989
  setConversation(existingConv);
732
990
  setParticipants(preview.participants);
733
991
  joinChannel(existingConv);
734
- setMessages(preview.lastMessages);
735
- setHasMore(preview.messageCount > preview.lastMessages.length);
736
- setPinnedMessages(preview.lastMessages.filter((m) => m.isPinned));
737
- setIsLoading(false);
738
992
  if (loadHistory) {
739
993
  fetchMessages(preview.conversationId).then((history) => {
740
994
  if (cancelled) return;
741
- setMessages((prev) => {
742
- const seen = new Set(prev.map((m) => m.id));
743
- const older = history.filter((m) => !seen.has(m.id));
744
- return [...older, ...prev];
745
- });
746
- setHasMore(history.length > 0);
747
- setPinnedMessages((prev) => {
748
- const fromHistory = history.filter((m) => m.isPinned);
749
- const seen = new Set(prev.map((m) => m.id));
750
- return [...fromHistory.filter((m) => !seen.has(m.id)), ...prev];
751
- });
995
+ setMessages(history);
996
+ setHasMore(history.length >= MESSAGES_PAGE_SIZE);
997
+ setPinnedMessages(history.filter((m) => m.isPinned));
998
+ setIsLoading(false);
752
999
  }).catch(() => {
1000
+ if (cancelled) return;
1001
+ setIsLoading(false);
753
1002
  });
1003
+ } else {
1004
+ setMessages([]);
1005
+ setHasMore(false);
1006
+ setPinnedMessages([]);
1007
+ setIsLoading(false);
754
1008
  }
755
1009
  } catch (err) {
756
1010
  if (cancelled) return;
@@ -762,10 +1016,9 @@ function useConversation({
762
1016
  init();
763
1017
  return () => {
764
1018
  cancelled = true;
765
- if (joinedConversationId.current) {
766
- socket.leaveConversation(joinedConversationId.current);
767
- joinedConversationId.current = null;
768
- }
1019
+ channelSubscription.current?.release();
1020
+ channelSubscription.current = null;
1021
+ joinedConversationId.current = null;
769
1022
  typingTimers.current.forEach((timer) => clearTimeout(timer));
770
1023
  typingTimers.current.clear();
771
1024
  markedReadIdsRef.current.clear();
@@ -831,7 +1084,7 @@ function useConversation({
831
1084
  const payload = {
832
1085
  body,
833
1086
  type: "text",
834
- ...replyTo ? { replyToId: replyTo.id } : {}
1087
+ ...replyTo ? { reply_to_id: replyTo.id } : {}
835
1088
  };
836
1089
  const { conv, persistedByCreate } = await ensureConversation(payload);
837
1090
  setReplyTo(null);
@@ -884,7 +1137,7 @@ function useConversation({
884
1137
  const payload = {
885
1138
  body: target.body,
886
1139
  type: "text",
887
- ...target.replyToId ? { replyToId: target.replyToId } : {}
1140
+ ...target.replyToId ? { reply_to_id: target.replyToId } : {}
888
1141
  };
889
1142
  try {
890
1143
  await socket.sendMessage(conversation.id, payload);
@@ -1181,17 +1434,26 @@ function DicomIcon({ size = 18, color = "currentColor" }) {
1181
1434
  }
1182
1435
  );
1183
1436
  }
1184
- function SettingsIcon({ size = 18, color = "currentColor" }) {
1185
- return /* @__PURE__ */ jsxRuntime.jsx(
1437
+ function PeopleIcon({ size = 18, color = "currentColor" }) {
1438
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1186
1439
  "svg",
1187
1440
  {
1188
1441
  xmlns: "http://www.w3.org/2000/svg",
1189
1442
  width: size,
1190
1443
  height: size,
1191
1444
  viewBox: "0 0 24 24",
1192
- fill: color,
1445
+ fill: "none",
1446
+ stroke: color,
1447
+ strokeWidth: "2",
1448
+ strokeLinecap: "round",
1449
+ strokeLinejoin: "round",
1193
1450
  "aria-hidden": "true",
1194
- children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" })
1451
+ children: [
1452
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }),
1453
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "9", cy: "7", r: "4" }),
1454
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M22 21v-2a4 4 0 0 0-3-3.87" }),
1455
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" })
1456
+ ]
1195
1457
  }
1196
1458
  );
1197
1459
  }
@@ -1209,102 +1471,6 @@ function BackIcon({ size = 22, color = "currentColor" }) {
1209
1471
  }
1210
1472
  );
1211
1473
  }
1212
-
1213
- // src/core/theme.ts
1214
- var FONT_SIZE = {
1215
- xs: "12px",
1216
- sm: "13px",
1217
- md: "15px",
1218
- lg: "17px",
1219
- xxl: "28px"
1220
- };
1221
- var FONT_WEIGHT = {
1222
- regular: 400,
1223
- medium: 500,
1224
- semibold: 600,
1225
- bold: 700
1226
- };
1227
- var LINE_HEIGHT = {
1228
- tight: 1.3,
1229
- normal: 1.45};
1230
- var SPACE = {
1231
- S1: "4px",
1232
- S2: "8px",
1233
- S3: "12px",
1234
- S4: "16px",
1235
- S5: "20px",
1236
- S6: "24px",
1237
- S12: "48px"
1238
- };
1239
- var RADIUS = {
1240
- sm: "4px",
1241
- md: "8px",
1242
- lg: "12px",
1243
- pill: "9999px",
1244
- full: "50%"
1245
- };
1246
- var COLOR = {
1247
- // Brand
1248
- primary: "#2563eb",
1249
- /** Tinted surface for chips/cards on brand-coloured states. */
1250
- primaryBg: "#dbeafe",
1251
- primaryFg: "#1d4ed8",
1252
- // Neutral grayscale — taken from Tailwind's zinc-leaning slate, the
1253
- // existing dominant family in the package.
1254
- white: "#ffffff",
1255
- neutral50: "#f9fafb",
1256
- neutral100: "#f3f4f6",
1257
- neutral200: "#e5e7eb",
1258
- neutral300: "#d1d5db",
1259
- neutral400: "#9ca3af",
1260
- neutral500: "#6b7280",
1261
- neutral600: "#4b5563",
1262
- neutral700: "#374151",
1263
- neutral800: "#1f2937",
1264
- neutral900: "#111827",
1265
- // Slate (used by day dividers + dialog title bar)
1266
- slate200: "#e2e8f0",
1267
- slate700: "#334155",
1268
- slate800: "#1e293b",
1269
- // Semantic
1270
- success: "#059669",
1271
- warning: "#d97706",
1272
- danger: "#dc2626",
1273
- dangerBg: "#fef2f2",
1274
- dangerBorder: "#fecaca",
1275
- dangerFg: "#b91c1c"
1276
- };
1277
- var SIZE = {
1278
- /** Default interactive height (text buttons, list rows). */
1279
- control: "40px",
1280
- /** Square icon-only buttons inside chrome (popup title, menu trigger). */
1281
- controlIcon: "36px",
1282
- /** Primary input controls (textarea send/attach). 44px = WCAG min. */
1283
- controlPrimary: "44px",
1284
- /** Avatar in the inbox row. */
1285
- avatar: "40px",
1286
- /** Unread dot. */
1287
- dot: "10px"
1288
- };
1289
- var SHADOW = {
1290
- /** Cards / floating popovers (message-actions menu). */
1291
- popover: "0 6px 18px rgba(0, 0, 0, 0.12)",
1292
- /** Dialog (CollabPopup). */
1293
- dialog: "0 8px 30px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08)",
1294
- /** Subtle separator shadow (sticky day divider, toast). */
1295
- subtle: "0 1px 2px rgba(0, 0, 0, 0.04)",
1296
- toast: "0 4px 12px rgba(0, 0, 0, 0.18)"
1297
- };
1298
- var Z_INDEX = {
1299
- /** Sticky day divider — above bubbles, below interactive popovers. */
1300
- sticky: 1,
1301
- /** Toasts inside a panel. */
1302
- toast: 20,
1303
- /** Floating CollabPopup dialog. */
1304
- dialog: 9999,
1305
- /** Portaled message-actions menu — must sit above the dialog. */
1306
- menu: 1e4
1307
- };
1308
1474
  function resolveDisplayName(patientData, displayName) {
1309
1475
  const localComplete = !!patientData.labName && !!patientData.displayOrderId;
1310
1476
  if (localComplete) return buildChannelName(patientData);
@@ -1312,6 +1478,7 @@ function resolveDisplayName(patientData, displayName) {
1312
1478
  }
1313
1479
  function PatientHeader({
1314
1480
  patientData,
1481
+ participants,
1315
1482
  onOpenDicom,
1316
1483
  onOpenSettings,
1317
1484
  onBack,
@@ -1321,15 +1488,28 @@ function PatientHeader({
1321
1488
  }) {
1322
1489
  const hasDicom = !!(patientData.studyId && patientData.storageId);
1323
1490
  const meta = [];
1324
- if (patientData.patientAge) meta.push({ label: "Age", value: String(patientData.patientAge) });
1325
- if (patientData.patientSex) meta.push({ label: "Sex", value: String(patientData.patientSex) });
1326
- if (patientData.studyType) meta.push({ label: "Modality", value: patientData.studyType });
1327
- if (patientData.bodyParts && patientData.bodyParts.length > 0) {
1328
- meta.push({ label: "Body part", value: patientData.bodyParts.join(", ") });
1491
+ if (patientData.patientAge && patientData.patientSex) {
1492
+ meta.push({
1493
+ key: "ageSex",
1494
+ label: "Age / Sex",
1495
+ value: `${patientData.patientAge} \xB7 ${patientData.patientSex}`
1496
+ });
1497
+ } else if (patientData.patientAge) {
1498
+ meta.push({ key: "age", label: "Age", value: String(patientData.patientAge) });
1499
+ } else if (patientData.patientSex) {
1500
+ meta.push({ key: "sex", label: "Sex", value: String(patientData.patientSex) });
1501
+ }
1502
+ if (patientData.studyType) {
1503
+ meta.push({ key: "modality", label: "Modality", value: patientData.studyType });
1329
1504
  }
1330
- if (patientData.referringPhysician) {
1331
- meta.push({ label: "Ref", value: patientData.referringPhysician });
1505
+ if (patientData.bodyParts && patientData.bodyParts.length > 0) {
1506
+ meta.push({
1507
+ key: "body",
1508
+ label: "Body part",
1509
+ value: patientData.bodyParts.join(", ")
1510
+ });
1332
1511
  }
1512
+ const hasActions = hasDicom && onOpenDicom || onOpenSettings;
1333
1513
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, style: styles.container, children: [
1334
1514
  !hideName && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.nameRow, children: [
1335
1515
  onBack && /* @__PURE__ */ jsxRuntime.jsx(
@@ -1345,54 +1525,58 @@ function PatientHeader({
1345
1525
  ),
1346
1526
  /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.nameText, children: resolveDisplayName(patientData, displayName) })
1347
1527
  ] }),
1348
- meta.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles.metaRow, children: meta.map((item) => /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles.metaItem, children: [
1349
- /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles.metaLabel, children: [
1350
- item.label,
1351
- ":"
1352
- ] }),
1353
- " ",
1354
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.metaValue, children: item.value })
1355
- ] }, item.label)) }),
1356
- hasDicom && onOpenDicom || onOpenSettings ? /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.actions, children: [
1357
- hasDicom && onOpenDicom && /* @__PURE__ */ jsxRuntime.jsxs(
1358
- "button",
1359
- {
1360
- onClick: onOpenDicom,
1361
- style: styles.dicomButton,
1362
- type: "button",
1363
- "aria-label": "View DICOM study",
1364
- children: [
1365
- /* @__PURE__ */ jsxRuntime.jsx(DicomIcon, { size: 18, color: COLOR.white }),
1366
- /* @__PURE__ */ jsxRuntime.jsx("span", { children: "View DICOM" })
1367
- ]
1368
- }
1369
- ),
1370
- onOpenSettings && /* @__PURE__ */ jsxRuntime.jsxs(
1371
- "button",
1372
- {
1373
- onClick: onOpenSettings,
1374
- style: styles.settingsButton,
1375
- type: "button",
1376
- "aria-label": "Open channel settings",
1377
- children: [
1378
- /* @__PURE__ */ jsxRuntime.jsx(SettingsIcon, { size: 18, color: COLOR.neutral700 }),
1379
- /* @__PURE__ */ jsxRuntime.jsx("span", { children: "Settings" })
1380
- ]
1381
- }
1382
- )
1383
- ] }) : null
1528
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.caseCard, children: [
1529
+ meta.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles.metaRow, children: meta.map((item) => /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.metaCol, children: [
1530
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.metaLabel, children: item.label }),
1531
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.metaValue, children: item.value })
1532
+ ] }, item.key)) }),
1533
+ hasActions && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.actions, children: [
1534
+ hasDicom && onOpenDicom && /* @__PURE__ */ jsxRuntime.jsxs(
1535
+ "button",
1536
+ {
1537
+ onClick: onOpenDicom,
1538
+ style: styles.dicomButton,
1539
+ type: "button",
1540
+ "aria-label": "View DICOM study",
1541
+ children: [
1542
+ /* @__PURE__ */ jsxRuntime.jsx(DicomIcon, { size: 18, color: COLOR.primary }),
1543
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: "View DICOM" })
1544
+ ]
1545
+ }
1546
+ ),
1547
+ onOpenSettings && /* @__PURE__ */ jsxRuntime.jsxs(
1548
+ "button",
1549
+ {
1550
+ onClick: onOpenSettings,
1551
+ style: styles.settingsIconButton,
1552
+ type: "button",
1553
+ "aria-label": `Open channel settings (${participants.length} participants)`,
1554
+ title: "Channel participants",
1555
+ children: [
1556
+ /* @__PURE__ */ jsxRuntime.jsx(PeopleIcon, { size: 18, color: COLOR.neutral600 }),
1557
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.participantCount, children: participants.length })
1558
+ ]
1559
+ }
1560
+ )
1561
+ ] })
1562
+ ] })
1384
1563
  ] });
1385
1564
  }
1386
1565
  var styles = {
1387
1566
  container: {
1388
- padding: `${SPACE.S3} ${SPACE.S4}`,
1389
- borderBottom: `1px solid ${COLOR.neutral200}`,
1390
- backgroundColor: COLOR.neutral50
1567
+ display: "flex",
1568
+ flexDirection: "column",
1569
+ backgroundColor: COLOR.primaryBg,
1570
+ borderBottom: `1px solid ${COLOR.neutral200}`
1391
1571
  },
1572
+ // Optional name row, only when `hideName` is false (compact-inbox path)
1392
1573
  nameRow: {
1393
1574
  display: "flex",
1394
1575
  alignItems: "center",
1395
- gap: SPACE.S2
1576
+ gap: SPACE.S2,
1577
+ padding: `${SPACE.S3} ${SPACE.S4} ${SPACE.S2}`,
1578
+ borderBottom: `1px solid ${COLOR.neutral200}`,
1579
+ backgroundColor: COLOR.white
1396
1580
  },
1397
1581
  backButton: {
1398
1582
  width: SIZE.controlIcon,
@@ -1409,65 +1593,98 @@ var styles = {
1409
1593
  },
1410
1594
  nameText: {
1411
1595
  fontSize: FONT_SIZE.lg,
1412
- fontWeight: FONT_WEIGHT.semibold,
1596
+ fontWeight: FONT_WEIGHT.bold,
1413
1597
  color: COLOR.neutral900,
1414
1598
  lineHeight: LINE_HEIGHT.tight,
1599
+ letterSpacing: "-0.01em",
1415
1600
  minWidth: 0,
1416
1601
  overflow: "hidden",
1417
1602
  textOverflow: "ellipsis",
1418
1603
  whiteSpace: "nowrap"
1419
1604
  },
1605
+ // Case card: meta columns flowing in a wrap-row, actions stacked
1606
+ // below. Each meta column is a small label-above-value pair so the
1607
+ // user can scan field names quickly without a legend.
1608
+ caseCard: {
1609
+ display: "flex",
1610
+ flexDirection: "column",
1611
+ gap: SPACE.S3,
1612
+ padding: `${SPACE.S3} ${SPACE.S5}`
1613
+ },
1614
+ // Wrap-row of stacked label/value columns. Compact column gap keeps
1615
+ // the row dense without crowding; row gap kicks in when columns
1616
+ // wrap to a second line on narrow popups.
1420
1617
  metaRow: {
1421
1618
  display: "flex",
1422
1619
  flexWrap: "wrap",
1423
- columnGap: SPACE.S3,
1424
- rowGap: SPACE.S1,
1425
- marginTop: SPACE.S1
1620
+ columnGap: SPACE.S5,
1621
+ rowGap: SPACE.S2,
1622
+ minWidth: 0
1426
1623
  },
1427
- metaItem: {
1428
- fontSize: FONT_SIZE.sm,
1429
- color: COLOR.neutral700,
1430
- whiteSpace: "nowrap"
1624
+ metaCol: {
1625
+ display: "flex",
1626
+ flexDirection: "column",
1627
+ gap: "2px",
1628
+ minWidth: 0
1431
1629
  },
1432
1630
  metaLabel: {
1631
+ fontSize: "11px",
1632
+ fontWeight: FONT_WEIGHT.bold,
1633
+ letterSpacing: "0.06em",
1634
+ textTransform: "uppercase",
1433
1635
  color: COLOR.neutral500,
1434
- fontWeight: FONT_WEIGHT.medium
1636
+ whiteSpace: "nowrap"
1435
1637
  },
1436
1638
  metaValue: {
1437
- color: COLOR.neutral900
1639
+ fontSize: FONT_SIZE.sm,
1640
+ fontWeight: FONT_WEIGHT.semibold,
1641
+ color: COLOR.neutral900,
1642
+ whiteSpace: "nowrap",
1643
+ overflow: "hidden",
1644
+ textOverflow: "ellipsis",
1645
+ maxWidth: "180px"
1438
1646
  },
1439
1647
  actions: {
1440
1648
  display: "flex",
1441
1649
  gap: SPACE.S2,
1442
- marginTop: SPACE.S3
1650
+ flexShrink: 0
1443
1651
  },
1444
1652
  dicomButton: {
1445
1653
  display: "inline-flex",
1446
1654
  alignItems: "center",
1447
1655
  gap: SPACE.S2,
1448
1656
  minHeight: SIZE.control,
1449
- padding: `${SPACE.S2} ${SPACE.S4}`,
1657
+ padding: `${SPACE.S2} 14px`,
1450
1658
  fontSize: FONT_SIZE.sm,
1451
- fontWeight: FONT_WEIGHT.medium,
1452
- color: COLOR.white,
1453
- backgroundColor: COLOR.primary,
1454
- border: "none",
1455
- borderRadius: RADIUS.md,
1659
+ fontWeight: FONT_WEIGHT.semibold,
1660
+ color: COLOR.primary,
1661
+ backgroundColor: COLOR.white,
1662
+ border: `1.5px solid ${COLOR.primary}`,
1663
+ borderRadius: RADIUS.lg,
1456
1664
  cursor: "pointer"
1457
1665
  },
1458
- settingsButton: {
1666
+ // Channel-info button: people icon + participant count. Click opens the
1667
+ // channel settings overlay (kept on the same handler as before so hosts
1668
+ // don't have to re-wire — the affordance just looks like a "members"
1669
+ // pill now instead of a gear).
1670
+ settingsIconButton: {
1671
+ minHeight: SIZE.control,
1459
1672
  display: "inline-flex",
1460
1673
  alignItems: "center",
1461
1674
  gap: SPACE.S2,
1462
- minHeight: SIZE.control,
1463
- padding: `${SPACE.S2} ${SPACE.S4}`,
1675
+ padding: `0 ${SPACE.S3}`,
1676
+ backgroundColor: COLOR.white,
1677
+ border: `1px solid ${COLOR.neutral200}`,
1678
+ borderRadius: RADIUS.lg,
1679
+ color: COLOR.neutral600,
1680
+ cursor: "pointer",
1681
+ flexShrink: 0
1682
+ },
1683
+ participantCount: {
1464
1684
  fontSize: FONT_SIZE.sm,
1465
- fontWeight: FONT_WEIGHT.medium,
1685
+ fontWeight: FONT_WEIGHT.semibold,
1466
1686
  color: COLOR.neutral700,
1467
- backgroundColor: COLOR.white,
1468
- border: `1px solid ${COLOR.neutral300}`,
1469
- borderRadius: RADIUS.md,
1470
- cursor: "pointer"
1687
+ fontVariantNumeric: "tabular-nums"
1471
1688
  }
1472
1689
  };
1473
1690
  function ReplyQuoteBlock({
@@ -1476,10 +1693,10 @@ function ReplyQuoteBlock({
1476
1693
  onClick,
1477
1694
  className
1478
1695
  }) {
1479
- const baseBg = inOwnBubble ? "rgba(255,255,255,0.18)" : COLOR.neutral100;
1696
+ const baseBg = inOwnBubble ? "rgba(255,255,255,0.22)" : COLOR.white;
1480
1697
  const barColor = inOwnBubble ? COLOR.white : COLOR.primary;
1481
1698
  const nameColor = inOwnBubble ? COLOR.white : COLOR.primary;
1482
- const bodyColor = inOwnBubble ? "rgba(255,255,255,0.85)" : COLOR.neutral600;
1699
+ const bodyColor = inOwnBubble ? "rgba(255,255,255,0.9)" : COLOR.neutral700;
1483
1700
  const preview = renderSnapshotPreview(snapshot);
1484
1701
  return /* @__PURE__ */ jsxRuntime.jsxs(
1485
1702
  "div",
@@ -1851,43 +2068,50 @@ var styles4 = {
1851
2068
  position: "relative",
1852
2069
  display: "inline-block"
1853
2070
  },
2071
+ // Pill-shaped trigger matching the prototype's `.nc-msg-act` style —
2072
+ // always visible (touch-friendly) rather than hover-only.
1854
2073
  trigger: {
1855
- width: SIZE.controlIcon,
1856
- height: SIZE.controlIcon,
1857
- display: "flex",
2074
+ display: "inline-flex",
1858
2075
  alignItems: "center",
1859
2076
  justifyContent: "center",
1860
- backgroundColor: "transparent",
1861
- border: "none",
1862
- borderRadius: RADIUS.full,
2077
+ width: "32px",
2078
+ height: "32px",
2079
+ backgroundColor: COLOR.white,
2080
+ border: `1px solid ${COLOR.neutral200}`,
2081
+ borderRadius: "999px",
1863
2082
  cursor: "pointer",
1864
- color: COLOR.neutral600
2083
+ color: COLOR.neutral600,
2084
+ transition: "background-color 120ms ease, color 120ms ease, border-color 120ms ease"
1865
2085
  },
1866
2086
  menu: {
1867
2087
  position: "fixed",
1868
- minWidth: "160px",
2088
+ minWidth: "200px",
1869
2089
  backgroundColor: COLOR.white,
1870
- border: `1px solid ${COLOR.neutral200}`,
1871
- borderRadius: RADIUS.md,
2090
+ border: `1px solid ${COLOR.neutral300}`,
2091
+ borderRadius: RADIUS.lg,
1872
2092
  boxShadow: SHADOW.popover,
1873
- padding: SPACE.S1,
2093
+ padding: "6px",
2094
+ display: "flex",
2095
+ flexDirection: "column",
2096
+ gap: "2px",
1874
2097
  // Above the floating popup (z-index dialog) but below any future modal.
1875
2098
  zIndex: Z_INDEX.menu
1876
2099
  },
1877
2100
  item: {
1878
2101
  display: "flex",
1879
2102
  alignItems: "center",
1880
- gap: SPACE.S3,
2103
+ gap: SPACE.S2,
1881
2104
  width: "100%",
1882
2105
  minHeight: SIZE.control,
1883
- padding: `${SPACE.S2} ${SPACE.S3}`,
2106
+ padding: `10px ${SPACE.S3}`,
1884
2107
  fontSize: FONT_SIZE.sm,
1885
- color: COLOR.neutral900,
2108
+ color: COLOR.neutral700,
1886
2109
  backgroundColor: "transparent",
1887
2110
  border: "none",
1888
- borderRadius: RADIUS.sm,
2111
+ borderRadius: RADIUS.md,
1889
2112
  cursor: "pointer",
1890
- textAlign: "left"
2113
+ textAlign: "left",
2114
+ fontFamily: "inherit"
1891
2115
  },
1892
2116
  itemDisabled: {
1893
2117
  color: COLOR.neutral400,
@@ -2001,71 +2225,116 @@ function MessageBubble({
2001
2225
  },
2002
2226
  children: [
2003
2227
  isOwn && actionsSlot,
2004
- /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles5.bubbleWrapper, children: /* @__PURE__ */ jsxRuntime.jsxs(
2005
- "div",
2006
- {
2007
- style: {
2008
- ...styles5.bubble,
2009
- ...isOwn ? styles5.ownBubble : styles5.otherBubble
2010
- },
2011
- children: [
2012
- message.isPinned && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles5.pinnedBadge, children: [
2013
- /* @__PURE__ */ jsxRuntime.jsx(PinIcon, { size: 12 }),
2014
- /* @__PURE__ */ jsxRuntime.jsx("span", { children: "Pinned" })
2015
- ] }),
2016
- message.replyToSnapshot && /* @__PURE__ */ jsxRuntime.jsx(
2017
- ReplyQuoteBlock,
2018
- {
2019
- snapshot: message.replyToSnapshot,
2020
- inOwnBubble: isOwn,
2021
- onClick: onReplyJumpTo
2022
- }
2023
- ),
2024
- !isOwn && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles5.senderRow, children: [
2025
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles5.senderName, children: message.senderName }),
2026
- /* @__PURE__ */ jsxRuntime.jsx(RoleBadge, { role: message.senderRole })
2027
- ] }),
2028
- message.type === "text" && /* @__PURE__ */ jsxRuntime.jsx(TextContent, { body: message.body, onDeepLinkClick }),
2029
- message.type === "audio" && /* @__PURE__ */ jsxRuntime.jsx(AudioContent, { mediaUrl: message.mediaUrl, duration: message.mediaDuration }),
2030
- message.type === "image" && /* @__PURE__ */ jsxRuntime.jsx(ImageContent, { mediaUrl: message.mediaUrl, fileName: message.fileName }),
2031
- message.type === "file" && /* @__PURE__ */ jsxRuntime.jsx(FileContent, { mediaUrl: message.mediaUrl, fileName: message.fileName }),
2032
- message.type === "deep_link" && /* @__PURE__ */ jsxRuntime.jsx(DeepLinkContent, { body: message.body, metadata: message.metadata, onDeepLinkClick }),
2033
- /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles5.timestamp, children: formatTime(message.insertedAt) }),
2034
- isOwn && message.status === "sending" && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles5.statusRow, children: [
2035
- /* @__PURE__ */ jsxRuntime.jsx(Spinner, { size: 12, color: "rgba(255,255,255,0.85)" }),
2036
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles5.statusText, children: "Sending\u2026" })
2037
- ] }),
2038
- isOwn && message.status === "failed" && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles5.statusRow, children: [
2039
- /* @__PURE__ */ jsxRuntime.jsx(AlertIcon, { size: 12, color: "#fecaca" }),
2040
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles5.statusText, children: "Not sent" }),
2041
- onRetry && /* @__PURE__ */ jsxRuntime.jsx(
2042
- "button",
2228
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles5.bubbleWrapper, children: [
2229
+ !isOwn && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles5.authorRow, children: [
2230
+ /* @__PURE__ */ jsxRuntime.jsx(
2231
+ "span",
2232
+ {
2233
+ style: {
2234
+ ...styles5.authorAvatar,
2235
+ backgroundColor: roleColor(message.senderRole)
2236
+ },
2237
+ "aria-hidden": "true",
2238
+ children: computeInitials(message.senderName)
2239
+ }
2240
+ ),
2241
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles5.authorName, children: message.senderName }),
2242
+ /* @__PURE__ */ jsxRuntime.jsx(RoleBadge, { role: message.senderRole })
2243
+ ] }),
2244
+ /* @__PURE__ */ jsxRuntime.jsxs(
2245
+ "div",
2246
+ {
2247
+ style: {
2248
+ ...styles5.bubble,
2249
+ ...isOwn ? styles5.ownBubble : styles5.otherBubble
2250
+ },
2251
+ children: [
2252
+ message.isPinned && /* @__PURE__ */ jsxRuntime.jsxs(
2253
+ "div",
2043
2254
  {
2044
- type: "button",
2045
- onClick: () => onRetry(message.id),
2046
- style: styles5.retryButton,
2047
- "aria-label": "Retry sending message",
2048
- children: "Retry"
2255
+ style: {
2256
+ ...styles5.pinnedBadge,
2257
+ ...isOwn ? styles5.pinnedBadgeOwn : styles5.pinnedBadgeOther
2258
+ },
2259
+ children: [
2260
+ /* @__PURE__ */ jsxRuntime.jsx(PinIcon, { size: 12 }),
2261
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: "Pinned" })
2262
+ ]
2049
2263
  }
2050
- )
2051
- ] }),
2052
- showSeenBy && isOwn && !message.status && /* @__PURE__ */ jsxRuntime.jsx(
2053
- SeenByIndicator,
2054
- {
2055
- readBy: message.readBy ?? [],
2056
- participants,
2057
- currentUserId,
2058
- senderId: message.senderId
2059
- }
2060
- )
2061
- ]
2062
- }
2063
- ) }),
2264
+ ),
2265
+ message.replyToSnapshot && /* @__PURE__ */ jsxRuntime.jsx(
2266
+ ReplyQuoteBlock,
2267
+ {
2268
+ snapshot: message.replyToSnapshot,
2269
+ inOwnBubble: isOwn,
2270
+ onClick: onReplyJumpTo
2271
+ }
2272
+ ),
2273
+ message.type === "text" && /* @__PURE__ */ jsxRuntime.jsx(TextContent, { body: message.body, onDeepLinkClick }),
2274
+ message.type === "audio" && /* @__PURE__ */ jsxRuntime.jsx(AudioContent, { mediaUrl: message.mediaUrl, duration: message.mediaDuration }),
2275
+ message.type === "image" && /* @__PURE__ */ jsxRuntime.jsx(ImageContent, { mediaUrl: message.mediaUrl, fileName: message.fileName }),
2276
+ message.type === "file" && /* @__PURE__ */ jsxRuntime.jsx(FileContent, { mediaUrl: message.mediaUrl, fileName: message.fileName }),
2277
+ message.type === "deep_link" && /* @__PURE__ */ jsxRuntime.jsx(DeepLinkContent, { body: message.body, metadata: message.metadata, onDeepLinkClick }),
2278
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles5.bubbleFoot, children: [
2279
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles5.timestamp, children: formatTime(message.insertedAt) }),
2280
+ isOwn && message.status === "sending" && /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles5.footStatus, children: [
2281
+ /* @__PURE__ */ jsxRuntime.jsx(Spinner, { size: 11, color: "rgba(255,255,255,0.85)" }),
2282
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: "Sending\u2026" })
2283
+ ] }),
2284
+ isOwn && message.status === "failed" && /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles5.footStatusFailed, children: [
2285
+ /* @__PURE__ */ jsxRuntime.jsx(AlertIcon, { size: 11, color: "#fecaca" }),
2286
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: "Not sent" }),
2287
+ onRetry && /* @__PURE__ */ jsxRuntime.jsx(
2288
+ "button",
2289
+ {
2290
+ type: "button",
2291
+ onClick: () => onRetry(message.id),
2292
+ style: styles5.retryButton,
2293
+ "aria-label": "Retry sending message",
2294
+ children: "Retry"
2295
+ }
2296
+ )
2297
+ ] }),
2298
+ showSeenBy && isOwn && !message.status && /* @__PURE__ */ jsxRuntime.jsx(
2299
+ SeenByIndicator,
2300
+ {
2301
+ readBy: message.readBy ?? [],
2302
+ participants,
2303
+ currentUserId,
2304
+ senderId: message.senderId
2305
+ }
2306
+ )
2307
+ ] })
2308
+ ]
2309
+ }
2310
+ )
2311
+ ] }),
2064
2312
  !isOwn && actionsSlot
2065
2313
  ]
2066
2314
  }
2067
2315
  );
2068
2316
  }
2317
+ function computeInitials(name) {
2318
+ return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]?.toUpperCase() ?? "").join("") || "?";
2319
+ }
2320
+ function roleColor(role) {
2321
+ switch (role) {
2322
+ case "radiologist":
2323
+ return "#4f46e5";
2324
+ // indigo (blue-leaning)
2325
+ case "lab":
2326
+ return "#2563eb";
2327
+ // brand blue
2328
+ case "physician":
2329
+ return "#0284c7";
2330
+ // sky
2331
+ case "admin":
2332
+ return "#64748b";
2333
+ // slate (muted)
2334
+ default:
2335
+ return "#6b7280";
2336
+ }
2337
+ }
2069
2338
  function TextContent({ body, onDeepLinkClick }) {
2070
2339
  if (body.includes(DEEP_LINK_PREFIX)) {
2071
2340
  const parts = body.split(new RegExp(`(${escapeRegex2(DEEP_LINK_PREFIX)}[\\w/.-]+)`, "g"));
@@ -2127,19 +2396,13 @@ function DeepLinkContent({
2127
2396
  );
2128
2397
  }
2129
2398
  function SystemBubble({ message }) {
2130
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles5.systemMessage, children: [
2399
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles5.systemWrapper, children: /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles5.systemPill, children: [
2131
2400
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: message.body }),
2132
2401
  /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles5.systemTime, children: formatTime(message.insertedAt) })
2133
- ] });
2402
+ ] }) });
2134
2403
  }
2135
2404
  function RoleBadge({ role }) {
2136
- const colors = {
2137
- radiologist: "#7c3aed",
2138
- lab: "#2563eb",
2139
- physician: "#059669",
2140
- admin: "#dc2626"
2141
- };
2142
- return /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles5.roleBadge, backgroundColor: `${colors[role]}15`, color: colors[role] }, children: ROLE_LABELS[role] ?? role });
2405
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles5.roleBadge, backgroundColor: roleColor(role) }, children: ROLE_LABELS[role] ?? role });
2143
2406
  }
2144
2407
  function formatTime(iso) {
2145
2408
  try {
@@ -2161,89 +2424,134 @@ var styles5 = {
2161
2424
  display: "flex",
2162
2425
  alignItems: "flex-end",
2163
2426
  gap: SPACE.S1,
2164
- marginBottom: SPACE.S2,
2427
+ marginBottom: SPACE.S3,
2165
2428
  paddingLeft: SPACE.S4,
2166
2429
  paddingRight: SPACE.S4
2167
2430
  },
2168
2431
  bubbleWrapper: {
2169
2432
  position: "relative",
2170
- maxWidth: "75%"
2433
+ maxWidth: "78%",
2434
+ display: "flex",
2435
+ flexDirection: "column",
2436
+ gap: "4px"
2171
2437
  },
2172
2438
  actionsSlot: {
2173
2439
  flexShrink: 0,
2174
2440
  alignSelf: "flex-end"
2175
2441
  },
2442
+ // Author row above the bubble — only rendered for others' messages.
2443
+ authorRow: {
2444
+ display: "flex",
2445
+ alignItems: "center",
2446
+ gap: SPACE.S2
2447
+ },
2448
+ authorAvatar: {
2449
+ width: "36px",
2450
+ height: "36px",
2451
+ borderRadius: "50%",
2452
+ color: COLOR.white,
2453
+ fontSize: FONT_SIZE.sm,
2454
+ fontWeight: FONT_WEIGHT.bold,
2455
+ display: "flex",
2456
+ alignItems: "center",
2457
+ justifyContent: "center",
2458
+ flexShrink: 0,
2459
+ letterSpacing: "0.02em"
2460
+ },
2461
+ authorName: {
2462
+ fontSize: FONT_SIZE.sm,
2463
+ fontWeight: FONT_WEIGHT.semibold,
2464
+ color: COLOR.neutral700,
2465
+ overflow: "hidden",
2466
+ textOverflow: "ellipsis",
2467
+ whiteSpace: "nowrap",
2468
+ minWidth: 0
2469
+ },
2176
2470
  bubble: {
2177
2471
  padding: `${SPACE.S3} ${SPACE.S4}`,
2178
- borderRadius: RADIUS.lg,
2179
- wordBreak: "break-word"
2472
+ borderRadius: "18px",
2473
+ wordBreak: "break-word",
2474
+ border: "1px solid transparent"
2180
2475
  },
2181
2476
  ownBubble: {
2182
2477
  backgroundColor: COLOR.primary,
2183
2478
  color: COLOR.white,
2184
- borderBottomRightRadius: RADIUS.sm
2479
+ borderColor: COLOR.primary,
2480
+ borderBottomRightRadius: "6px"
2185
2481
  },
2186
2482
  otherBubble: {
2187
2483
  backgroundColor: COLOR.neutral100,
2188
2484
  color: COLOR.neutral900,
2189
- borderBottomLeftRadius: RADIUS.sm
2485
+ borderColor: COLOR.neutral200,
2486
+ borderBottomLeftRadius: "6px"
2190
2487
  },
2191
2488
  pinnedBadge: {
2192
2489
  display: "inline-flex",
2193
2490
  alignItems: "center",
2194
- gap: SPACE.S1,
2195
- fontSize: FONT_SIZE.xs,
2196
- fontWeight: FONT_WEIGHT.semibold,
2197
- marginBottom: SPACE.S1,
2198
- color: COLOR.neutral500
2491
+ gap: "4px",
2492
+ fontSize: "11px",
2493
+ fontWeight: FONT_WEIGHT.bold,
2494
+ marginBottom: SPACE.S2,
2495
+ padding: `2px ${SPACE.S2}`,
2496
+ borderRadius: "999px",
2497
+ textTransform: "uppercase",
2498
+ letterSpacing: "0.05em"
2199
2499
  },
2200
- senderRow: {
2201
- display: "flex",
2202
- alignItems: "center",
2203
- gap: SPACE.S2,
2204
- marginBottom: SPACE.S1
2500
+ pinnedBadgeOther: {
2501
+ color: "#b8680f",
2502
+ backgroundColor: "#fff7e0"
2205
2503
  },
2206
- senderName: {
2207
- fontSize: FONT_SIZE.sm,
2208
- fontWeight: FONT_WEIGHT.semibold,
2209
- color: COLOR.neutral700
2504
+ pinnedBadgeOwn: {
2505
+ color: "#fff4d6",
2506
+ backgroundColor: "rgba(255, 255, 255, 0.2)"
2210
2507
  },
2211
2508
  roleBadge: {
2212
- fontSize: FONT_SIZE.xs,
2213
- fontWeight: FONT_WEIGHT.semibold,
2214
- padding: `2px ${SPACE.S2}`,
2215
- borderRadius: RADIUS.sm
2509
+ fontSize: "10px",
2510
+ fontWeight: FONT_WEIGHT.bold,
2511
+ padding: "2px 7px",
2512
+ borderRadius: RADIUS.sm,
2513
+ textTransform: "uppercase",
2514
+ letterSpacing: "0.06em",
2515
+ color: COLOR.white,
2516
+ flexShrink: 0
2216
2517
  },
2217
2518
  textBody: {
2218
2519
  margin: 0,
2219
2520
  fontSize: FONT_SIZE.md,
2220
- lineHeight: LINE_HEIGHT.normal
2521
+ lineHeight: 1.5
2522
+ },
2523
+ bubbleFoot: {
2524
+ display: "flex",
2525
+ alignItems: "center",
2526
+ justifyContent: "flex-end",
2527
+ gap: SPACE.S2,
2528
+ marginTop: SPACE.S2,
2529
+ fontSize: "11px",
2530
+ opacity: 0.85
2221
2531
  },
2222
2532
  timestamp: {
2223
- fontSize: FONT_SIZE.xs,
2533
+ fontSize: "11px",
2224
2534
  color: "inherit",
2225
- opacity: 0.8,
2226
- marginTop: SPACE.S1,
2227
- textAlign: "right"
2535
+ fontVariantNumeric: "tabular-nums"
2228
2536
  },
2229
- statusRow: {
2230
- display: "flex",
2537
+ footStatus: {
2538
+ display: "inline-flex",
2231
2539
  alignItems: "center",
2232
- gap: SPACE.S2,
2233
- marginTop: SPACE.S1,
2234
- fontSize: FONT_SIZE.xs,
2235
- justifyContent: "flex-end",
2236
- color: "rgba(255, 255, 255, 0.9)"
2540
+ gap: "4px",
2541
+ fontSize: "11px"
2237
2542
  },
2238
- statusText: {
2239
- opacity: 0.9
2543
+ footStatusFailed: {
2544
+ display: "inline-flex",
2545
+ alignItems: "center",
2546
+ gap: "4px",
2547
+ fontSize: "11px"
2240
2548
  },
2241
2549
  retryButton: {
2242
2550
  background: "transparent",
2243
2551
  border: "none",
2244
2552
  color: COLOR.white,
2245
2553
  fontWeight: FONT_WEIGHT.semibold,
2246
- fontSize: FONT_SIZE.xs,
2554
+ fontSize: "11px",
2247
2555
  textDecoration: "underline",
2248
2556
  cursor: "pointer",
2249
2557
  padding: `0 ${SPACE.S1}`
@@ -2306,18 +2614,25 @@ var styles5 = {
2306
2614
  color: COLOR.neutral500,
2307
2615
  fontFamily: "monospace"
2308
2616
  },
2309
- systemMessage: {
2617
+ systemWrapper: {
2310
2618
  display: "flex",
2311
2619
  justifyContent: "center",
2620
+ margin: `${SPACE.S2} 0`
2621
+ },
2622
+ systemPill: {
2623
+ display: "inline-flex",
2312
2624
  alignItems: "center",
2313
2625
  gap: SPACE.S2,
2314
- padding: `${SPACE.S1} ${SPACE.S4}`,
2315
- fontSize: FONT_SIZE.sm,
2626
+ padding: `5px ${SPACE.S3}`,
2627
+ backgroundColor: COLOR.neutral100,
2628
+ borderRadius: "999px",
2629
+ fontSize: FONT_SIZE.xs,
2316
2630
  color: COLOR.neutral500
2317
2631
  },
2318
2632
  systemTime: {
2319
- fontSize: FONT_SIZE.xs,
2320
- color: COLOR.neutral400
2633
+ fontSize: "11px",
2634
+ color: COLOR.neutral400,
2635
+ fontVariantNumeric: "tabular-nums"
2321
2636
  }
2322
2637
  };
2323
2638
  function ChatIllustration({ size = 96 }) {
@@ -2456,6 +2771,7 @@ var MessageList = React4.forwardRef(function MessageList2({
2456
2771
  const containerRef = React4.useRef(null);
2457
2772
  const bottomRef = React4.useRef(null);
2458
2773
  const prevMessageCount = React4.useRef(messages.length);
2774
+ const hasInitiallyScrolledRef = React4.useRef(false);
2459
2775
  React4.useImperativeHandle(
2460
2776
  ref,
2461
2777
  () => ({
@@ -2473,7 +2789,31 @@ var MessageList = React4.forwardRef(function MessageList2({
2473
2789
  }),
2474
2790
  []
2475
2791
  );
2792
+ React4.useLayoutEffect(() => {
2793
+ const container = containerRef.current;
2794
+ if (!container || messages.length === 0) return;
2795
+ if (hasInitiallyScrolledRef.current) return;
2796
+ hasInitiallyScrolledRef.current = true;
2797
+ prevMessageCount.current = messages.length;
2798
+ const firstUnread = messages.find(
2799
+ (m) => m.type !== "system" && m.senderId !== currentUserId && !(m.readBy ?? []).includes(currentUserId)
2800
+ );
2801
+ if (firstUnread) {
2802
+ const el = container.querySelector(
2803
+ `[data-message-id="${firstUnread.id}"]`
2804
+ );
2805
+ if (el) {
2806
+ const containerRect = container.getBoundingClientRect();
2807
+ const elRect = el.getBoundingClientRect();
2808
+ const offset = elRect.top - containerRect.top + container.scrollTop;
2809
+ container.scrollTop = Math.max(0, offset - 16);
2810
+ return;
2811
+ }
2812
+ }
2813
+ container.scrollTop = container.scrollHeight;
2814
+ }, [messages, currentUserId]);
2476
2815
  React4.useEffect(() => {
2816
+ if (!hasInitiallyScrolledRef.current) return;
2477
2817
  if (messages.length > prevMessageCount.current) {
2478
2818
  const lastMessage = messages[messages.length - 1];
2479
2819
  const isOwnMessage = lastMessage?.senderId === currentUserId;
@@ -2532,7 +2872,11 @@ var MessageList = React4.forwardRef(function MessageList2({
2532
2872
  const showDivider = !!currentBucket && currentBucket !== lastValidBucket;
2533
2873
  if (currentBucket) lastValidBucket = currentBucket;
2534
2874
  return /* @__PURE__ */ jsxRuntime.jsxs(React4__default.default.Fragment, { children: [
2535
- showDivider && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles6.dayDivider, role: "separator", "aria-label": "Date", children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles6.dayDividerPill, children: formatDayDivider(message.insertedAt) }) }),
2875
+ showDivider && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles6.dayDivider, role: "separator", "aria-label": "Date", children: [
2876
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles6.dayHr, "aria-hidden": "true" }),
2877
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles6.dayLabel, children: formatDayDivider(message.insertedAt) }),
2878
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles6.dayHr, "aria-hidden": "true" })
2879
+ ] }),
2536
2880
  /* @__PURE__ */ jsxRuntime.jsx("div", { "data-message-id": message.id, "data-natoe-message-bubble": true, style: styles6.bubbleSlot, children: /* @__PURE__ */ jsxRuntime.jsx(
2537
2881
  MessageBubble,
2538
2882
  {
@@ -2631,23 +2975,32 @@ var styles6 = {
2631
2975
  },
2632
2976
  dayDivider: {
2633
2977
  display: "flex",
2634
- justifyContent: "center",
2635
- margin: `${SPACE.S3} 0 ${SPACE.S2}`,
2978
+ alignItems: "center",
2979
+ gap: SPACE.S3,
2980
+ margin: `${SPACE.S4} 0 ${SPACE.S3}`,
2636
2981
  position: "sticky",
2637
2982
  top: SPACE.S1,
2638
2983
  zIndex: Z_INDEX.sticky,
2639
2984
  pointerEvents: "none"
2640
2985
  },
2641
- dayDividerPill: {
2642
- display: "inline-block",
2643
- padding: `${SPACE.S1} ${SPACE.S3}`,
2644
- fontSize: FONT_SIZE.sm,
2645
- fontWeight: FONT_WEIGHT.medium,
2646
- color: COLOR.slate700,
2647
- backgroundColor: COLOR.slate200,
2648
- borderRadius: RADIUS.lg,
2649
- letterSpacing: "0.2px",
2650
- boxShadow: SHADOW.subtle
2986
+ dayHr: {
2987
+ flex: 1,
2988
+ height: "1px",
2989
+ backgroundColor: COLOR.neutral200
2990
+ },
2991
+ dayLabel: {
2992
+ flexShrink: 0,
2993
+ fontSize: FONT_SIZE.xs,
2994
+ fontWeight: FONT_WEIGHT.semibold,
2995
+ color: COLOR.neutral500,
2996
+ textTransform: "uppercase",
2997
+ letterSpacing: "0.06em",
2998
+ // Subtle pill background so the label is readable when it overlays
2999
+ // bubbles passing under it during sticky scroll. Without this the
3000
+ // hairlines visually run through the text on tinted backgrounds.
3001
+ padding: `2px ${SPACE.S2}`,
3002
+ backgroundColor: COLOR.white,
3003
+ borderRadius: RADIUS.sm
2651
3004
  },
2652
3005
  bubbleSlot: {
2653
3006
  animation: "natoe-colab-message-in 180ms ease-out"
@@ -2901,11 +3254,11 @@ function MessageInput({
2901
3254
  {
2902
3255
  onClick: () => fileInputRef.current?.click(),
2903
3256
  disabled: disabled || isSending,
2904
- style: styles8.iconButton,
3257
+ style: styles8.attachButton,
2905
3258
  "aria-label": "Attach file",
2906
3259
  title: "Attach file",
2907
3260
  type: "button",
2908
- children: /* @__PURE__ */ jsxRuntime.jsx(AttachIcon, { size: 22, color: "#374151" })
3261
+ children: /* @__PURE__ */ jsxRuntime.jsx(AttachIcon, { size: 22, color: COLOR.neutral700 })
2909
3262
  }
2910
3263
  ),
2911
3264
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -2944,7 +3297,7 @@ function MessageInput({
2944
3297
  style: styles8.textarea
2945
3298
  }
2946
3299
  ),
2947
- /* @__PURE__ */ jsxRuntime.jsx(
3300
+ /* @__PURE__ */ jsxRuntime.jsxs(
2948
3301
  "button",
2949
3302
  {
2950
3303
  onClick: handleSendText,
@@ -2953,10 +3306,13 @@ function MessageInput({
2953
3306
  title: "Send message",
2954
3307
  style: {
2955
3308
  ...styles8.sendButton,
2956
- opacity: text.trim() ? 1 : 0.4
3309
+ opacity: text.trim() ? 1 : 0.5
2957
3310
  },
2958
3311
  type: "button",
2959
- children: /* @__PURE__ */ jsxRuntime.jsx(SendIcon, { size: 20, color: COLOR.white })
3312
+ children: [
3313
+ /* @__PURE__ */ jsxRuntime.jsx(SendIcon, { size: 20, color: COLOR.white }),
3314
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles8.sendLabel, children: "Send" })
3315
+ ]
2960
3316
  }
2961
3317
  )
2962
3318
  ] })
@@ -3018,46 +3374,64 @@ var styles8 = {
3018
3374
  inputBar: {
3019
3375
  display: "flex",
3020
3376
  alignItems: "flex-end",
3021
- gap: SPACE.S2,
3022
- padding: `${SPACE.S3} ${SPACE.S3}`
3023
- },
3024
- iconButton: {
3025
- width: SIZE.controlPrimary,
3026
- height: SIZE.controlPrimary,
3377
+ gap: "10px",
3378
+ padding: `${SPACE.S3} ${SPACE.S4} 14px`
3379
+ },
3380
+ // 48px square tile with a 14px corner radius — visually grounds the
3381
+ // attach affordance as part of the composer chrome rather than a stray
3382
+ // round icon button.
3383
+ attachButton: {
3384
+ width: "48px",
3385
+ height: "48px",
3027
3386
  display: "flex",
3028
3387
  alignItems: "center",
3029
3388
  justifyContent: "center",
3030
- backgroundColor: "transparent",
3031
- border: "none",
3032
- borderRadius: RADIUS.full,
3389
+ backgroundColor: COLOR.neutral100,
3390
+ border: `1.5px solid ${COLOR.neutral200}`,
3391
+ borderRadius: "14px",
3033
3392
  cursor: "pointer",
3034
- flexShrink: 0
3393
+ flexShrink: 0,
3394
+ color: COLOR.neutral700,
3395
+ transition: "background-color 120ms ease, color 120ms ease, border-color 120ms ease"
3035
3396
  },
3036
3397
  textarea: {
3037
3398
  flex: 1,
3038
- padding: `${SPACE.S3} ${SPACE.S4}`,
3399
+ minHeight: "48px",
3400
+ maxHeight: "140px",
3401
+ padding: "13px 16px",
3039
3402
  fontSize: FONT_SIZE.md,
3040
3403
  lineHeight: LINE_HEIGHT.normal,
3041
- border: `1px solid ${COLOR.neutral300}`,
3042
- borderRadius: RADIUS.pill,
3404
+ color: COLOR.neutral900,
3405
+ backgroundColor: COLOR.neutral100,
3406
+ border: `1.5px solid ${COLOR.neutral200}`,
3407
+ borderRadius: "14px",
3043
3408
  resize: "none",
3044
3409
  outline: "none",
3045
3410
  fontFamily: "inherit",
3046
- maxHeight: "120px",
3047
- minHeight: SIZE.controlPrimary
3411
+ transition: "background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease"
3048
3412
  },
3413
+ // Pill-shaped send: icon + "Send" label. Always labelled (never icon-
3414
+ // only) so the affordance is obvious to first-time users.
3049
3415
  sendButton: {
3050
- width: SIZE.controlPrimary,
3051
- height: SIZE.controlPrimary,
3052
- display: "flex",
3416
+ display: "inline-flex",
3053
3417
  alignItems: "center",
3054
- justifyContent: "center",
3418
+ gap: "6px",
3419
+ minWidth: "48px",
3420
+ height: "48px",
3421
+ padding: `0 ${SPACE.S4}`,
3055
3422
  backgroundColor: COLOR.primary,
3056
3423
  color: COLOR.white,
3057
3424
  border: "none",
3058
- borderRadius: RADIUS.full,
3425
+ borderRadius: "14px",
3059
3426
  cursor: "pointer",
3060
- flexShrink: 0
3427
+ flexShrink: 0,
3428
+ fontFamily: "inherit",
3429
+ fontSize: "15px",
3430
+ fontWeight: FONT_WEIGHT.semibold,
3431
+ transition: "opacity 120ms ease, background-color 120ms ease"
3432
+ },
3433
+ sendLabel: {
3434
+ lineHeight: 1
3061
3435
  }};
3062
3436
  function ParticipantsList({
3063
3437
  participants,
@@ -3114,10 +3488,14 @@ function fallbackName(role) {
3114
3488
  }
3115
3489
  function RoleBadge2({ role }) {
3116
3490
  const colors = {
3117
- radiologist: { bg: "#f5f3ff", text: "#7c3aed" },
3491
+ radiologist: { bg: "#eef2ff", text: "#4f46e5" },
3492
+ // indigo
3118
3493
  lab: { bg: "#eff6ff", text: "#2563eb" },
3119
- physician: { bg: "#ecfdf5", text: "#059669" },
3120
- admin: { bg: "#fef2f2", text: "#dc2626" }
3494
+ // brand blue
3495
+ physician: { bg: "#e0f2fe", text: "#0284c7" },
3496
+ // sky
3497
+ admin: { bg: "#f1f5f9", text: "#64748b" }
3498
+ // slate
3121
3499
  };
3122
3500
  const color = colors[role];
3123
3501
  return /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles9.roleBadge, backgroundColor: color.bg, color: color.text }, children: role });
@@ -3726,46 +4104,21 @@ var styles12 = {
3726
4104
  letterSpacing: "0.4px"
3727
4105
  }
3728
4106
  };
3729
-
3730
- // src/core/styles.ts
3731
- var FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
3732
- var ROOT_CLASS = "natoe-colab-root";
3733
- var GLOBAL_STYLE_ID = "natoe-colab-global-styles";
3734
- var GLOBAL_CSS = `
3735
- @keyframes natoe-colab-spin { to { transform: rotate(360deg); } }
3736
-
3737
- @keyframes natoe-colab-message-in {
3738
- from { opacity: 0; transform: translateY(4px); }
3739
- to { opacity: 1; transform: translateY(0); }
3740
- }
3741
-
3742
- /* High-contrast adjustments \u2014 older users on OS-level high-contrast mode
3743
- get heavier borders and stronger text without losing the package look. */
3744
- @media (prefers-contrast: more) {
3745
- .${ROOT_CLASS} {
3746
- color: #000000;
3747
- }
3748
- .${ROOT_CLASS} button {
3749
- outline: 1px solid currentColor;
3750
- }
3751
- }
3752
-
3753
- /* Honour reduced-motion preferences by killing entry animations. */
3754
- @media (prefers-reduced-motion: reduce) {
3755
- .${ROOT_CLASS} [data-natoe-message-bubble] {
3756
- animation: none !important;
3757
- }
3758
- }
3759
- `;
3760
- function ensureGlobalStyles() {
3761
- if (typeof document === "undefined") return;
3762
- if (document.getElementById(GLOBAL_STYLE_ID)) return;
3763
- const style = document.createElement("style");
3764
- style.id = GLOBAL_STYLE_ID;
3765
- style.textContent = GLOBAL_CSS;
3766
- document.head.appendChild(style);
3767
- }
3768
4107
  ensureGlobalStyles();
4108
+ var DARK_THEME_OVERRIDES = {
4109
+ ["--natoe-colab-white"]: "#0b0b0c",
4110
+ ["--natoe-colab-neutral-50"]: "#18181c",
4111
+ ["--natoe-colab-neutral-100"]: "#1f1f23",
4112
+ ["--natoe-colab-neutral-200"]: "#2a2a2e",
4113
+ ["--natoe-colab-neutral-300"]: "#3f3f44",
4114
+ ["--natoe-colab-neutral-400"]: "#6b7280",
4115
+ ["--natoe-colab-neutral-500"]: "#9ca3af",
4116
+ ["--natoe-colab-neutral-600"]: "#cbd5e1",
4117
+ ["--natoe-colab-neutral-700"]: "#e5e7eb",
4118
+ ["--natoe-colab-neutral-800"]: "#f3f4f6",
4119
+ ["--natoe-colab-neutral-900"]: "#ffffff",
4120
+ ["--natoe-colab-primary-bg"]: "rgba(37, 99, 235, 0.18)"
4121
+ };
3769
4122
  function CollabPanel({
3770
4123
  orderId,
3771
4124
  patientData,
@@ -3774,6 +4127,7 @@ function CollabPanel({
3774
4127
  onBack,
3775
4128
  hidePatientName = false,
3776
4129
  onConversationChange,
4130
+ themeMode = "light",
3777
4131
  className,
3778
4132
  style
3779
4133
  }) {
@@ -3849,16 +4203,21 @@ function CollabPanel({
3849
4203
  }
3850
4204
  };
3851
4205
  const pinDisabled = pinnedMessages.length >= MAX_PINNED_MESSAGES;
4206
+ const containerStyle = {
4207
+ ...panelStyles.container,
4208
+ ...themeMode === "dark" ? DARK_THEME_OVERRIDES : {},
4209
+ ...style
4210
+ };
3852
4211
  if (error) {
3853
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: { ...panelStyles.container, ...style }, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { style: panelStyles.errorState, children: [
4212
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { style: panelStyles.errorState, children: [
3854
4213
  /* @__PURE__ */ jsxRuntime.jsx("p", { style: panelStyles.errorTitle, children: "Unable to load conversation" }),
3855
4214
  /* @__PURE__ */ jsxRuntime.jsx("p", { style: panelStyles.errorMessage, children: error })
3856
4215
  ] }) });
3857
4216
  }
3858
4217
  if (isLoading && !conversation) {
3859
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: { ...panelStyles.container, ...style }, children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: panelStyles.loadingState, children: "Loading conversation..." }) });
4218
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: panelStyles.loadingState, children: "Loading conversation..." }) });
3860
4219
  }
3861
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, style: { ...panelStyles.container, ...style }, children: [
4220
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: [
3862
4221
  showSettings && conversation ? /* @__PURE__ */ jsxRuntime.jsx(
3863
4222
  ChannelSettings,
3864
4223
  {
@@ -4013,6 +4372,7 @@ function CollabPopup({
4013
4372
  isOpen,
4014
4373
  onClose,
4015
4374
  onBack,
4375
+ onMinimize,
4016
4376
  initialPosition,
4017
4377
  width = 380,
4018
4378
  height = 520,
@@ -4128,35 +4488,48 @@ function CollabPopup({
4128
4488
  "button",
4129
4489
  {
4130
4490
  onClick: onBack,
4131
- style: styles13.titleButton,
4132
- "aria-label": "Go back",
4491
+ style: styles13.titleSquare,
4492
+ "aria-label": "Back to messages",
4133
4493
  title: "Back",
4134
4494
  type: "button",
4135
- children: "\u2190"
4495
+ children: /* @__PURE__ */ jsxRuntime.jsx(BackIcon, { size: 18, color: COLOR.neutral700 })
4136
4496
  }
4137
4497
  ),
4138
- /* @__PURE__ */ jsxRuntime.jsx("span", { id: titleId, style: { ...styles13.titleText, flex: 1 }, children: titleText }),
4498
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles13.titleCenter, children: [
4499
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { id: titleId, style: styles13.patientName, children: patientData.patientName || titleText }),
4500
+ (patientData.labName || patientData.displayOrderId) && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles13.subRow, children: [
4501
+ patientData.labName && /* @__PURE__ */ jsxRuntime.jsx("span", { children: patientData.labName }),
4502
+ patientData.labName && patientData.displayOrderId && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles13.subDot, "aria-hidden": "true", children: "\xB7" }),
4503
+ patientData.displayOrderId && /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles13.mono, children: [
4504
+ "#",
4505
+ patientData.displayOrderId.slice(-4)
4506
+ ] })
4507
+ ] })
4508
+ ] }),
4139
4509
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles13.titleActions, children: [
4140
4510
  /* @__PURE__ */ jsxRuntime.jsx(
4141
4511
  "button",
4142
4512
  {
4143
- onClick: () => setIsMinimized(!isMinimized),
4144
- style: styles13.titleButton,
4145
- "aria-label": isMinimized ? "Expand chat" : "Minimize chat",
4146
- title: isMinimized ? "Expand" : "Minimize",
4513
+ onClick: () => {
4514
+ if (onMinimize) onMinimize();
4515
+ else setIsMinimized(!isMinimized);
4516
+ },
4517
+ style: styles13.titleSquare,
4518
+ "aria-label": onMinimize ? "Minimize chat" : isMinimized ? "Expand chat" : "Minimize chat",
4519
+ title: onMinimize ? "Minimize" : isMinimized ? "Expand" : "Minimize",
4147
4520
  type: "button",
4148
- children: isMinimized ? /* @__PURE__ */ jsxRuntime.jsx(ExpandIcon, { size: 16, color: COLOR.slate200 }) : /* @__PURE__ */ jsxRuntime.jsx(MinimizeIcon, { size: 16, color: COLOR.slate200 })
4521
+ children: onMinimize || !isMinimized ? /* @__PURE__ */ jsxRuntime.jsx(MinimizeIcon, { size: 18, color: COLOR.neutral700 }) : /* @__PURE__ */ jsxRuntime.jsx(ExpandIcon, { size: 18, color: COLOR.neutral700 })
4149
4522
  }
4150
4523
  ),
4151
4524
  /* @__PURE__ */ jsxRuntime.jsx(
4152
4525
  "button",
4153
4526
  {
4154
4527
  onClick: onClose,
4155
- style: styles13.titleButton,
4528
+ style: styles13.titleSquare,
4156
4529
  "aria-label": "Close chat",
4157
4530
  title: "Close",
4158
4531
  type: "button",
4159
- children: /* @__PURE__ */ jsxRuntime.jsx(CloseIcon, { size: 16, color: COLOR.slate200 })
4532
+ children: /* @__PURE__ */ jsxRuntime.jsx(CloseIcon, { size: 18, color: COLOR.neutral700 })
4160
4533
  }
4161
4534
  )
4162
4535
  ] })
@@ -4179,51 +4552,78 @@ var styles13 = {
4179
4552
  container: {
4180
4553
  position: "fixed",
4181
4554
  zIndex: Z_INDEX.dialog,
4182
- borderRadius: RADIUS.lg,
4183
- boxShadow: SHADOW.dialog,
4555
+ borderRadius: "20px",
4556
+ boxShadow: "0 20px 48px rgba(20, 21, 28, 0.16), 0 6px 16px rgba(20, 21, 28, 0.08)",
4184
4557
  overflow: "hidden",
4185
4558
  display: "flex",
4186
4559
  flexDirection: "column",
4187
4560
  backgroundColor: COLOR.white,
4188
- border: `1px solid ${COLOR.neutral200}`,
4561
+ border: `1px solid ${COLOR.neutral300}`,
4189
4562
  transition: "height 0.2s ease",
4190
4563
  fontFamily: FONT_STACK
4191
4564
  },
4192
4565
  titleBar: {
4193
4566
  display: "flex",
4194
- justifyContent: "space-between",
4195
4567
  alignItems: "center",
4196
- padding: `0 ${SPACE.S2} 0 ${SPACE.S4}`,
4197
- height: "52px",
4198
- backgroundColor: COLOR.slate800,
4568
+ gap: SPACE.S2,
4569
+ padding: `10px ${SPACE.S3}`,
4570
+ backgroundColor: COLOR.white,
4571
+ borderBottom: `1px solid ${COLOR.neutral200}`,
4199
4572
  cursor: "grab",
4200
4573
  userSelect: "none",
4201
4574
  flexShrink: 0
4202
4575
  },
4203
- titleText: {
4204
- fontSize: FONT_SIZE.md,
4205
- fontWeight: FONT_WEIGHT.medium,
4206
- color: COLOR.white,
4576
+ titleCenter: {
4577
+ flex: 1,
4578
+ minWidth: 0,
4579
+ display: "flex",
4580
+ flexDirection: "column",
4581
+ gap: "2px"
4582
+ },
4583
+ patientName: {
4584
+ margin: 0,
4585
+ fontSize: FONT_SIZE.lg,
4586
+ fontWeight: FONT_WEIGHT.bold,
4587
+ letterSpacing: "-0.01em",
4588
+ color: COLOR.neutral900,
4207
4589
  overflow: "hidden",
4208
4590
  textOverflow: "ellipsis",
4209
4591
  whiteSpace: "nowrap"
4210
4592
  },
4593
+ subRow: {
4594
+ display: "flex",
4595
+ alignItems: "center",
4596
+ gap: SPACE.S2,
4597
+ fontSize: FONT_SIZE.sm,
4598
+ color: COLOR.neutral500,
4599
+ overflow: "hidden",
4600
+ textOverflow: "ellipsis",
4601
+ whiteSpace: "nowrap"
4602
+ },
4603
+ subDot: {
4604
+ color: COLOR.neutral400
4605
+ },
4606
+ mono: {
4607
+ fontFamily: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
4608
+ fontVariantNumeric: "tabular-nums"
4609
+ },
4211
4610
  titleActions: {
4212
4611
  display: "flex",
4213
4612
  gap: SPACE.S1,
4214
4613
  flexShrink: 0
4215
4614
  },
4216
- titleButton: {
4217
- width: SIZE.control,
4218
- height: SIZE.control,
4615
+ titleSquare: {
4616
+ width: "36px",
4617
+ height: "36px",
4219
4618
  display: "flex",
4220
4619
  alignItems: "center",
4221
4620
  justifyContent: "center",
4222
- backgroundColor: "transparent",
4223
- border: "none",
4621
+ backgroundColor: COLOR.neutral100,
4622
+ border: `1px solid ${COLOR.neutral200}`,
4224
4623
  borderRadius: RADIUS.md,
4225
4624
  cursor: "pointer",
4226
- color: COLOR.slate200
4625
+ color: COLOR.neutral700,
4626
+ flexShrink: 0
4227
4627
  },
4228
4628
  panelWrapper: {
4229
4629
  flex: 1,
@@ -4253,6 +4653,7 @@ function useInlineCollab({
4253
4653
  const elementRef = React4.useRef(null);
4254
4654
  const observerRef = React4.useRef(null);
4255
4655
  const subscribedConversationIdRef = React4.useRef(null);
4656
+ const channelSubscriptionRef = React4.useRef(null);
4256
4657
  const trimToLimit = React4.useCallback(
4257
4658
  (msgs) => msgs.length > messageLimit ? msgs.slice(msgs.length - messageLimit) : msgs,
4258
4659
  [messageLimit]
@@ -4290,7 +4691,9 @@ function useInlineCollab({
4290
4691
  const subscribe = React4.useCallback(
4291
4692
  (conversationId) => {
4292
4693
  if (subscribedConversationIdRef.current === conversationId) return;
4293
- socket.joinConversation(conversationId, {
4694
+ channelSubscriptionRef.current?.release();
4695
+ channelSubscriptionRef.current = null;
4696
+ const subscription = socket.joinConversation(conversationId, {
4294
4697
  onMessage: (msg) => {
4295
4698
  setMessages((prev) => trimToLimit([...prev, msg]));
4296
4699
  if (msg.senderId !== config.userId) {
@@ -4306,19 +4709,18 @@ function useInlineCollab({
4306
4709
  setParticipants((prev) => prev.filter((p) => p.userId !== participant.userId));
4307
4710
  }
4308
4711
  });
4712
+ channelSubscriptionRef.current = subscription;
4309
4713
  subscribedConversationIdRef.current = conversationId;
4310
4714
  setIsSubscribed(true);
4311
4715
  },
4312
4716
  [socket, trimToLimit, config.userId]
4313
4717
  );
4314
4718
  const unsubscribe = React4.useCallback(() => {
4315
- const convId = subscribedConversationIdRef.current;
4316
- if (convId) {
4317
- socket.leaveConversation(convId);
4318
- subscribedConversationIdRef.current = null;
4319
- setIsSubscribed(false);
4320
- }
4321
- }, [socket]);
4719
+ channelSubscriptionRef.current?.release();
4720
+ channelSubscriptionRef.current = null;
4721
+ subscribedConversationIdRef.current = null;
4722
+ setIsSubscribed(false);
4723
+ }, []);
4322
4724
  const containerRef = React4.useCallback(
4323
4725
  (element) => {
4324
4726
  if (observerRef.current) {
@@ -4460,16 +4862,52 @@ function useInlineCollab({
4460
4862
  };
4461
4863
  }
4462
4864
  ensureGlobalStyles();
4865
+ function palette(mode) {
4866
+ if (mode === "dark") {
4867
+ return {
4868
+ bg: "transparent",
4869
+ border: "transparent",
4870
+ previewBorder: "#1f1f23",
4871
+ senderName: "#f3f4f6",
4872
+ messageText: "#cbd5e1",
4873
+ systemText: "#6b7280",
4874
+ inputBg: "#18181c",
4875
+ inputBorder: "#2a2a2e",
4876
+ inputText: "#e5e7eb",
4877
+ audioBg: "rgba(37, 99, 235, 0.18)",
4878
+ audioFg: "#93c5fd",
4879
+ expandBorder: "#2a2a2e",
4880
+ expandColor: "#9ca3af"
4881
+ };
4882
+ }
4883
+ return {
4884
+ bg: COLOR.white,
4885
+ border: COLOR.neutral200,
4886
+ previewBorder: COLOR.neutral100,
4887
+ senderName: COLOR.neutral700,
4888
+ messageText: COLOR.neutral600,
4889
+ systemText: COLOR.neutral400,
4890
+ inputBg: "transparent",
4891
+ inputBorder: COLOR.neutral200,
4892
+ inputText: COLOR.neutral900,
4893
+ audioBg: COLOR.primaryBg,
4894
+ audioFg: COLOR.primary,
4895
+ expandBorder: COLOR.neutral200,
4896
+ expandColor: COLOR.neutral500
4897
+ };
4898
+ }
4463
4899
  function CollabInline({
4464
4900
  orderId,
4465
4901
  patientData,
4466
4902
  participantIds,
4467
4903
  onExpand,
4468
- messageLimit = 5,
4904
+ messageLimit = 1,
4469
4905
  placeholder = "Type a message about this case...",
4906
+ mode = "light",
4470
4907
  className,
4471
4908
  style
4472
4909
  }) {
4910
+ const pal = palette(mode);
4473
4911
  const {
4474
4912
  hasConversation,
4475
4913
  messages,
@@ -4481,11 +4919,17 @@ function CollabInline({
4481
4919
  sendAudioMessage,
4482
4920
  containerRef
4483
4921
  } = useInlineCollab({ orderId, patientData, participantIds, messageLimit });
4922
+ const containerStyle = {
4923
+ ...styles14.container,
4924
+ backgroundColor: pal.bg,
4925
+ borderColor: pal.border,
4926
+ ...style
4927
+ };
4484
4928
  if (isLoading && !hasConversation) {
4485
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: { ...styles14.container, ...style }, children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.loadingState, children: "Loading..." }) });
4929
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.loadingState, children: "Loading..." }) });
4486
4930
  }
4487
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: containerRef, className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: { ...styles14.container, ...style }, children: [
4488
- hasConversation && messages.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.preview, children: messages.slice(-messageLimit).map((message) => /* @__PURE__ */ jsxRuntime.jsx(InlineMessageRow, { message }, message.id)) }),
4931
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: containerRef, className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: [
4932
+ hasConversation && messages.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { ...styles14.preview, borderBottomColor: pal.previewBorder }, children: messages.slice(-messageLimit).map((message) => /* @__PURE__ */ jsxRuntime.jsx(InlineMessageRow, { message, pal }, message.id)) }),
4489
4933
  error && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.error, children: error }),
4490
4934
  /* @__PURE__ */ jsxRuntime.jsx(
4491
4935
  InlineInputBar,
@@ -4496,45 +4940,54 @@ function CollabInline({
4496
4940
  unreadCount: hasConversation ? unreadCount : 0,
4497
4941
  isLive: isSubscribed,
4498
4942
  showExpand: hasConversation && !!onExpand,
4499
- onExpand
4943
+ onExpand,
4944
+ pal
4500
4945
  }
4501
4946
  )
4502
4947
  ] });
4503
4948
  }
4504
- function InlineMessageRow({ message }) {
4949
+ function InlineMessageRow({ message, pal }) {
4505
4950
  if (message.type === "system") {
4506
- return /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.systemRow, children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles14.systemText, children: message.body }) });
4951
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.systemRow, children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.systemText, color: pal.systemText }, children: message.body }) });
4507
4952
  }
4508
4953
  if (message.type === "audio" && message.mediaUrl) {
4509
4954
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles14.messageRow, children: [
4510
4955
  /* @__PURE__ */ jsxRuntime.jsx(RoleDot, { role: message.senderRole }),
4511
- /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles14.senderName, children: [
4956
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { ...styles14.senderName, color: pal.senderName }, children: [
4512
4957
  message.senderName,
4513
4958
  ":"
4514
4959
  ] }),
4515
- /* @__PURE__ */ jsxRuntime.jsx(InlineAudioPlayer, { url: message.mediaUrl, duration: message.mediaDuration })
4960
+ /* @__PURE__ */ jsxRuntime.jsx(InlineAudioPlayer, { url: message.mediaUrl, duration: message.mediaDuration, pal })
4516
4961
  ] });
4517
4962
  }
4518
4963
  const preview = renderMessagePreview(message);
4519
4964
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles14.messageRow, children: [
4520
4965
  /* @__PURE__ */ jsxRuntime.jsx(RoleDot, { role: message.senderRole }),
4521
- /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles14.senderName, children: [
4966
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { ...styles14.senderName, color: pal.senderName }, children: [
4522
4967
  message.senderName,
4523
4968
  ":"
4524
4969
  ] }),
4525
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles14.messageText, children: preview })
4970
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.messageText, color: pal.messageText }, children: preview })
4526
4971
  ] });
4527
4972
  }
4528
4973
  function RoleDot({ role }) {
4529
4974
  const colors = {
4530
- radiologist: "#7c3aed",
4975
+ radiologist: "#4f46e5",
4976
+ // indigo
4531
4977
  lab: "#2563eb",
4532
- physician: "#059669",
4533
- admin: "#dc2626"
4978
+ // brand blue
4979
+ physician: "#0284c7",
4980
+ // sky
4981
+ admin: "#64748b"
4982
+ // slate
4534
4983
  };
4535
4984
  return /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.roleDot, backgroundColor: colors[role] } });
4536
4985
  }
4537
- function InlineAudioPlayer({ url, duration }) {
4986
+ function InlineAudioPlayer({
4987
+ url,
4988
+ duration,
4989
+ pal
4990
+ }) {
4538
4991
  const audioRef = React4.useRef(null);
4539
4992
  const [isPlaying, setIsPlaying] = React4.useState(false);
4540
4993
  const [currentTime, setCurrentTime] = React4.useState(0);
@@ -4576,7 +5029,7 @@ function InlineAudioPlayer({ url, duration }) {
4576
5029
  const total = duration ?? 0;
4577
5030
  const remaining = Math.max(0, total - Math.floor(currentTime));
4578
5031
  const displayTime = isPlaying ? remaining : total;
4579
- return /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles14.audioPlayer, children: [
5032
+ return /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { ...styles14.audioPlayer, backgroundColor: pal.audioBg, borderColor: pal.audioBg }, children: [
4580
5033
  /* @__PURE__ */ jsxRuntime.jsx(
4581
5034
  "button",
4582
5035
  {
@@ -4588,15 +5041,15 @@ function InlineAudioPlayer({ url, duration }) {
4588
5041
  }
4589
5042
  ),
4590
5043
  /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles14.audioWaveform, children: [
4591
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "40%" } }),
4592
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "80%" } }),
4593
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "60%" } }),
4594
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "90%" } }),
4595
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "50%" } }),
4596
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "70%" } }),
4597
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "40%" } })
5044
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "40%", backgroundColor: pal.audioFg } }),
5045
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "80%", backgroundColor: pal.audioFg } }),
5046
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "60%", backgroundColor: pal.audioFg } }),
5047
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "90%", backgroundColor: pal.audioFg } }),
5048
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "50%", backgroundColor: pal.audioFg } }),
5049
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "70%", backgroundColor: pal.audioFg } }),
5050
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "40%", backgroundColor: pal.audioFg } })
4598
5051
  ] }),
4599
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles14.audioDuration, children: formatDuration2(displayTime) }),
5052
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.audioDuration, color: pal.audioFg }, children: formatDuration2(displayTime) }),
4600
5053
  /* @__PURE__ */ jsxRuntime.jsx("audio", { ref: audioRef, src: url, preload: "metadata" })
4601
5054
  ] });
4602
5055
  }
@@ -4626,7 +5079,8 @@ function InlineInputBar({
4626
5079
  unreadCount,
4627
5080
  isLive,
4628
5081
  showExpand,
4629
- onExpand
5082
+ onExpand,
5083
+ pal
4630
5084
  }) {
4631
5085
  const [text, setText] = React4.useState("");
4632
5086
  const [isSending, setIsSending] = React4.useState(false);
@@ -4663,7 +5117,12 @@ function InlineInputBar({
4663
5117
  onKeyDown: handleKeyDown,
4664
5118
  placeholder,
4665
5119
  disabled: isSending,
4666
- style: styles14.input
5120
+ style: {
5121
+ ...styles14.input,
5122
+ backgroundColor: pal.inputBg,
5123
+ borderColor: pal.inputBorder,
5124
+ color: pal.inputText
5125
+ }
4667
5126
  }
4668
5127
  ),
4669
5128
  unreadCount > 0 && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles14.unreadBadge, title: `${unreadCount} unread`, children: unreadCount > 99 ? "99+" : unreadCount }),
@@ -4683,7 +5142,11 @@ function InlineInputBar({
4683
5142
  "button",
4684
5143
  {
4685
5144
  onClick: onExpand,
4686
- style: styles14.expandButton,
5145
+ style: {
5146
+ ...styles14.expandButton,
5147
+ borderColor: pal.expandBorder,
5148
+ color: pal.expandColor
5149
+ },
4687
5150
  title: "Expand to full chat",
4688
5151
  type: "button",
4689
5152
  children: "\u26F6"
@@ -4952,6 +5415,10 @@ function ConversationListItem({
4952
5415
  }) {
4953
5416
  const hasUnread = item.unreadCount > 0;
4954
5417
  const displayName = item.name || item.patientSnapshot?.patientName || "Unknown";
5418
+ const studyType = item.patientSnapshot?.studyType;
5419
+ const patientId = item.patientSnapshot?.patientId;
5420
+ const showStudyRow = !!(studyType || patientId);
5421
+ const initials = computeInitials2(item.patientSnapshot?.patientName || displayName);
4955
5422
  return /* @__PURE__ */ jsxRuntime.jsxs(
4956
5423
  "button",
4957
5424
  {
@@ -4959,15 +5426,11 @@ function ConversationListItem({
4959
5426
  onClick,
4960
5427
  style: {
4961
5428
  ...styles15.container,
4962
- ...isSelected ? styles15.selected : {},
4963
- ...hasUnread ? styles15.unread : {}
5429
+ ...isSelected ? styles15.selected : {}
4964
5430
  },
4965
5431
  type: "button",
4966
5432
  children: [
4967
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles15.avatarWrapper, children: [
4968
- item.picture ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: item.picture, alt: "", style: styles15.avatar }) : /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles15.avatarFallback, children: (displayName).charAt(0).toUpperCase() }),
4969
- hasUnread && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles15.unreadDot })
4970
- ] }),
5433
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles15.avatarWrapper, children: item.picture ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: item.picture, alt: "", style: styles15.avatarImg }) : /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles15.avatarTile, "aria-hidden": "true", children: initials }) }),
4971
5434
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles15.content, children: [
4972
5435
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles15.topRow, children: [
4973
5436
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -4991,101 +5454,120 @@ function ConversationListItem({
4991
5454
  }
4992
5455
  )
4993
5456
  ] }),
5457
+ showStudyRow && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles15.studyRow, children: [
5458
+ studyType && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles15.studyType, children: studyType }),
5459
+ studyType && patientId && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles15.studyDivider, children: "\xB7" }),
5460
+ patientId && /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles15.studyId, children: [
5461
+ "MRN ",
5462
+ patientId
5463
+ ] })
5464
+ ] }),
4994
5465
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles15.bottomRow, children: [
4995
- /* @__PURE__ */ jsxRuntime.jsx(
4996
- "span",
4997
- {
4998
- style: {
4999
- ...styles15.preview,
5000
- ...hasUnread ? styles15.previewUnread : {}
5001
- },
5002
- children: renderLastMessagePreview(item.lastMessage)
5003
- }
5004
- ),
5005
- hasUnread && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles15.badge, children: item.unreadCount > 99 ? "99+" : item.unreadCount })
5466
+ /* @__PURE__ */ jsxRuntime.jsx(PreviewLine, { message: item.lastMessage, hasUnread }),
5467
+ hasUnread && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles15.badge, "aria-label": `${item.unreadCount} unread`, children: item.unreadCount > 99 ? "99+" : item.unreadCount })
5006
5468
  ] })
5007
5469
  ] })
5008
5470
  ]
5009
5471
  }
5010
5472
  );
5011
5473
  }
5012
- function renderLastMessagePreview(message) {
5013
- if (!message) return "No messages yet";
5014
- const prefix = message.senderName ? `${message.senderName}: ` : "";
5474
+ function PreviewLine({ message, hasUnread }) {
5475
+ if (!message) {
5476
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles15.preview, children: "No messages yet" });
5477
+ }
5478
+ if (message.type === "system") {
5479
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles15.preview, children: message.body });
5480
+ }
5481
+ const text = previewText(message);
5482
+ const sender = message.senderName ? `${message.senderName.split(" ")[0]}:` : "";
5483
+ return /* @__PURE__ */ jsxRuntime.jsxs(
5484
+ "span",
5485
+ {
5486
+ style: {
5487
+ ...styles15.preview,
5488
+ ...hasUnread ? styles15.previewUnread : {}
5489
+ },
5490
+ children: [
5491
+ sender && /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles15.previewSender, children: [
5492
+ sender,
5493
+ " "
5494
+ ] }),
5495
+ text
5496
+ ]
5497
+ }
5498
+ );
5499
+ }
5500
+ function previewText(message) {
5015
5501
  switch (message.type) {
5016
5502
  case "audio":
5017
- return `${prefix}\u{1F3A4} Voice message`;
5503
+ return "Voice message";
5018
5504
  case "image":
5019
- return `${prefix}\u{1F4F7} ${message.fileName || "Image"}`;
5505
+ return message.fileName || "Image";
5020
5506
  case "file":
5021
- return `${prefix}\u{1F4CE} ${message.fileName || "File"}`;
5507
+ return message.fileName || "File";
5022
5508
  case "deep_link":
5023
- return `${prefix}\u{1F517} ${message.body}`;
5024
- case "system":
5025
- return message.body;
5509
+ return message.body || "Shared link";
5026
5510
  default: {
5027
5511
  const body = message.body || "";
5028
- const preview = body.length > 60 ? `${body.slice(0, 60)}\u2026` : body;
5029
- return `${prefix}${preview}`;
5512
+ return body.length > 60 ? `${body.slice(0, 60)}\u2026` : body;
5030
5513
  }
5031
5514
  }
5032
5515
  }
5516
+ function computeInitials2(name) {
5517
+ return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() ?? "").join("") || "?";
5518
+ }
5033
5519
  var styles15 = {
5034
5520
  container: {
5035
5521
  display: "flex",
5036
- alignItems: "center",
5037
- gap: SPACE.S3,
5038
- padding: `${SPACE.S3} ${SPACE.S3}`,
5522
+ alignItems: "flex-start",
5523
+ gap: "14px",
5039
5524
  width: "100%",
5525
+ padding: `14px ${SPACE.S5}`,
5526
+ minHeight: "84px",
5040
5527
  backgroundColor: "transparent",
5041
5528
  border: "none",
5042
- borderBottom: `1px solid ${COLOR.neutral100}`,
5529
+ borderLeft: "4px solid transparent",
5043
5530
  cursor: "pointer",
5044
- textAlign: "left"
5531
+ textAlign: "left",
5532
+ color: "inherit",
5533
+ fontFamily: "inherit",
5534
+ transition: "background-color 120ms ease"
5045
5535
  },
5046
5536
  selected: {
5047
- backgroundColor: COLOR.primaryBg
5537
+ backgroundColor: COLOR.primaryBg,
5538
+ borderLeftColor: COLOR.primary
5048
5539
  },
5049
- unread: {},
5050
5540
  avatarWrapper: {
5051
- position: "relative",
5052
- width: SIZE.avatar,
5053
- height: SIZE.avatar,
5541
+ width: "52px",
5542
+ height: "52px",
5054
5543
  flexShrink: 0
5055
5544
  },
5056
- avatar: {
5057
- width: SIZE.avatar,
5058
- height: SIZE.avatar,
5059
- borderRadius: RADIUS.full,
5060
- objectFit: "cover"
5545
+ avatarImg: {
5546
+ width: "52px",
5547
+ height: "52px",
5548
+ borderRadius: RADIUS.xl,
5549
+ objectFit: "cover",
5550
+ boxShadow: "inset 0 -2px 0 rgba(0, 0, 0, 0.08)"
5061
5551
  },
5062
- avatarFallback: {
5063
- width: SIZE.avatar,
5064
- height: SIZE.avatar,
5065
- borderRadius: RADIUS.full,
5066
- backgroundColor: COLOR.neutral200,
5552
+ avatarTile: {
5553
+ width: "52px",
5554
+ height: "52px",
5555
+ borderRadius: RADIUS.xl,
5556
+ backgroundColor: COLOR.primary,
5557
+ color: COLOR.white,
5067
5558
  display: "flex",
5068
5559
  alignItems: "center",
5069
5560
  justifyContent: "center",
5070
- fontSize: FONT_SIZE.md,
5071
- fontWeight: FONT_WEIGHT.semibold,
5072
- color: COLOR.neutral500
5073
- },
5074
- unreadDot: {
5075
- position: "absolute",
5076
- top: "0",
5077
- left: "0",
5078
- width: SIZE.dot,
5079
- height: SIZE.dot,
5080
- borderRadius: RADIUS.full,
5081
- backgroundColor: COLOR.primary,
5082
- border: `2px solid ${COLOR.white}`
5561
+ fontSize: FONT_SIZE.lg,
5562
+ fontWeight: FONT_WEIGHT.bold,
5563
+ letterSpacing: "0.02em",
5564
+ boxShadow: "inset 0 -2px 0 rgba(0, 0, 0, 0.08)"
5083
5565
  },
5084
5566
  content: {
5085
5567
  flex: 1,
5086
5568
  display: "flex",
5087
5569
  flexDirection: "column",
5088
- gap: "2px",
5570
+ gap: "3px",
5089
5571
  minWidth: 0
5090
5572
  },
5091
5573
  topRow: {
@@ -5110,123 +5592,194 @@ var styles15 = {
5110
5592
  time: {
5111
5593
  fontSize: FONT_SIZE.xs,
5112
5594
  color: COLOR.neutral500,
5595
+ fontVariantNumeric: "tabular-nums",
5113
5596
  flexShrink: 0
5114
5597
  },
5115
5598
  timeUnread: {
5116
5599
  color: COLOR.primary,
5117
5600
  fontWeight: FONT_WEIGHT.semibold
5118
5601
  },
5602
+ studyRow: {
5603
+ display: "flex",
5604
+ alignItems: "center",
5605
+ gap: SPACE.S2,
5606
+ minWidth: 0
5607
+ },
5608
+ studyType: {
5609
+ fontSize: FONT_SIZE.sm,
5610
+ color: COLOR.neutral500,
5611
+ overflow: "hidden",
5612
+ textOverflow: "ellipsis",
5613
+ whiteSpace: "nowrap"
5614
+ },
5615
+ studyDivider: {
5616
+ fontSize: FONT_SIZE.sm,
5617
+ color: COLOR.neutral400,
5618
+ flexShrink: 0
5619
+ },
5620
+ studyId: {
5621
+ fontSize: FONT_SIZE.sm,
5622
+ color: COLOR.neutral500,
5623
+ fontFamily: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
5624
+ fontVariantNumeric: "tabular-nums",
5625
+ flexShrink: 0
5626
+ },
5119
5627
  bottomRow: {
5120
5628
  display: "flex",
5121
5629
  justifyContent: "space-between",
5122
5630
  alignItems: "center",
5123
- gap: SPACE.S2
5631
+ gap: SPACE.S2,
5632
+ marginTop: "2px"
5124
5633
  },
5125
5634
  preview: {
5126
5635
  fontSize: FONT_SIZE.sm,
5127
- color: COLOR.neutral600,
5636
+ color: COLOR.neutral500,
5128
5637
  overflow: "hidden",
5129
5638
  textOverflow: "ellipsis",
5130
5639
  whiteSpace: "nowrap",
5131
5640
  flex: 1,
5132
5641
  minWidth: 0
5133
5642
  },
5643
+ previewSender: {
5644
+ color: COLOR.neutral700,
5645
+ fontWeight: FONT_WEIGHT.semibold
5646
+ },
5134
5647
  previewUnread: {
5135
5648
  color: COLOR.neutral900,
5136
5649
  fontWeight: FONT_WEIGHT.medium
5137
5650
  },
5138
5651
  badge: {
5139
- minWidth: "20px",
5140
- height: "20px",
5141
- padding: `0 ${SPACE.S2}`,
5652
+ minWidth: "22px",
5653
+ height: "22px",
5654
+ padding: "0 9px",
5142
5655
  fontSize: FONT_SIZE.xs,
5143
- fontWeight: FONT_WEIGHT.semibold,
5656
+ fontWeight: FONT_WEIGHT.bold,
5144
5657
  color: COLOR.white,
5145
- backgroundColor: COLOR.primary,
5658
+ backgroundColor: COLOR.danger,
5146
5659
  borderRadius: RADIUS.pill,
5147
5660
  display: "flex",
5148
5661
  alignItems: "center",
5149
5662
  justifyContent: "center",
5663
+ fontVariantNumeric: "tabular-nums",
5150
5664
  flexShrink: 0
5151
5665
  }
5152
5666
  };
5667
+ function SearchIcon({ size = 20, color = "currentColor" }) {
5668
+ return /* @__PURE__ */ jsxRuntime.jsxs(
5669
+ "svg",
5670
+ {
5671
+ xmlns: "http://www.w3.org/2000/svg",
5672
+ width: size,
5673
+ height: size,
5674
+ viewBox: "0 0 24 24",
5675
+ fill: "none",
5676
+ stroke: color,
5677
+ strokeWidth: "2",
5678
+ strokeLinecap: "round",
5679
+ strokeLinejoin: "round",
5680
+ "aria-hidden": "true",
5681
+ children: [
5682
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "11", cy: "11", r: "7" }),
5683
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M20 20l-4-4" })
5684
+ ]
5685
+ }
5686
+ );
5687
+ }
5153
5688
  function ConversationList({
5154
5689
  conversations,
5155
5690
  selectedId,
5156
5691
  isLoading,
5157
5692
  error,
5158
5693
  onSelect,
5159
- title = "Conversations",
5160
5694
  className
5161
5695
  }) {
5696
+ const { totalUnread } = useCollab();
5162
5697
  const [query, setQuery] = React4.useState("");
5163
- const [showUnreadOnly, setShowUnreadOnly] = React4.useState(false);
5698
+ const [filter, setFilter] = React4.useState(
5699
+ () => totalUnread > 0 ? "unread" : "all"
5700
+ );
5701
+ const [searchFocused, setSearchFocused] = React4.useState(false);
5702
+ const unreadCount = React4.useMemo(
5703
+ () => conversations.filter((c) => c.unreadCount > 0).length,
5704
+ [conversations]
5705
+ );
5706
+ const autoDefaultedRef = React4.useRef(false);
5707
+ React4.useEffect(() => {
5708
+ if (autoDefaultedRef.current) return;
5709
+ if (conversations.length === 0) return;
5710
+ autoDefaultedRef.current = true;
5711
+ if (unreadCount > 0) setFilter("unread");
5712
+ }, [conversations.length, unreadCount]);
5164
5713
  const filtered = React4.useMemo(() => {
5165
5714
  let result = conversations;
5166
- if (showUnreadOnly) {
5715
+ if (filter === "unread") {
5167
5716
  result = result.filter((c) => c.unreadCount > 0);
5168
5717
  }
5169
5718
  const q = query.trim().toLowerCase();
5170
5719
  if (q) {
5171
5720
  result = result.filter((c) => {
5172
- const patient = c.patientSnapshot?.patientName?.toLowerCase() || "";
5173
5721
  const name = c.name.toLowerCase();
5174
- return name.includes(q) || patient.includes(q);
5722
+ const patient = c.patientSnapshot?.patientName?.toLowerCase() || "";
5723
+ const studyType = c.patientSnapshot?.studyType?.toLowerCase() || "";
5724
+ const patientId = c.patientSnapshot?.patientId?.toLowerCase() || "";
5725
+ return name.includes(q) || patient.includes(q) || studyType.includes(q) || patientId.includes(q);
5175
5726
  });
5176
5727
  }
5177
5728
  return result;
5178
- }, [conversations, query, showUnreadOnly]);
5179
- const totalUnread = conversations.reduce((sum, c) => sum + c.unreadCount, 0);
5729
+ }, [conversations, query, filter]);
5180
5730
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, style: styles16.container, children: [
5181
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles16.header, children: [
5182
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles16.titleRow, children: [
5183
- /* @__PURE__ */ jsxRuntime.jsx("h3", { style: styles16.title, children: title }),
5184
- totalUnread > 0 && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles16.totalBadge, children: totalUnread })
5185
- ] }),
5186
- /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles16.searchRow, children: /* @__PURE__ */ jsxRuntime.jsx(
5187
- "input",
5731
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles16.tabs, children: [
5732
+ /* @__PURE__ */ jsxRuntime.jsx(
5733
+ TabButton,
5188
5734
  {
5189
- type: "text",
5190
- value: query,
5191
- onChange: (e) => setQuery(e.target.value),
5192
- placeholder: "Search patients or channels...",
5193
- style: styles16.searchInput
5735
+ label: "Unread",
5736
+ count: unreadCount,
5737
+ isActive: filter === "unread",
5738
+ onClick: () => setFilter("unread")
5194
5739
  }
5195
- ) }),
5196
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles16.filterRow, children: [
5197
- /* @__PURE__ */ jsxRuntime.jsx(
5198
- "button",
5199
- {
5200
- onClick: () => setShowUnreadOnly(false),
5201
- style: {
5202
- ...styles16.filterButton,
5203
- ...!showUnreadOnly ? styles16.filterButtonActive : {}
5204
- },
5205
- type: "button",
5206
- children: "All"
5207
- }
5208
- ),
5209
- /* @__PURE__ */ jsxRuntime.jsxs(
5210
- "button",
5211
- {
5212
- onClick: () => setShowUnreadOnly(true),
5213
- style: {
5214
- ...styles16.filterButton,
5215
- ...showUnreadOnly ? styles16.filterButtonActive : {}
5216
- },
5217
- type: "button",
5218
- children: [
5219
- "Unread ",
5220
- totalUnread > 0 && `(${totalUnread})`
5221
- ]
5222
- }
5223
- )
5224
- ] })
5740
+ ),
5741
+ /* @__PURE__ */ jsxRuntime.jsx(
5742
+ TabButton,
5743
+ {
5744
+ label: "All",
5745
+ count: conversations.length,
5746
+ isActive: filter === "all",
5747
+ onClick: () => setFilter("all")
5748
+ }
5749
+ )
5225
5750
  ] }),
5751
+ /* @__PURE__ */ jsxRuntime.jsxs(
5752
+ "div",
5753
+ {
5754
+ style: {
5755
+ ...styles16.search,
5756
+ ...searchFocused ? styles16.searchFocused : {}
5757
+ },
5758
+ children: [
5759
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles16.searchIcon, "aria-hidden": "true", children: /* @__PURE__ */ jsxRuntime.jsx(SearchIcon, { size: 20, color: COLOR.neutral500 }) }),
5760
+ /* @__PURE__ */ jsxRuntime.jsx(
5761
+ "input",
5762
+ {
5763
+ type: "text",
5764
+ value: query,
5765
+ onChange: (e) => setQuery(e.target.value),
5766
+ onFocus: () => setSearchFocused(true),
5767
+ onBlur: () => setSearchFocused(false),
5768
+ placeholder: "Search by patient name or MRN",
5769
+ "aria-label": "Search conversations",
5770
+ style: styles16.searchInput
5771
+ }
5772
+ )
5773
+ ]
5774
+ }
5775
+ ),
5226
5776
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles16.list, children: [
5227
- isLoading && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles16.state, children: "Loading conversations..." }),
5228
- error && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles16.errorState, children: error }),
5229
- !isLoading && !error && filtered.length === 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles16.state, children: query ? "No matching conversations" : "No conversations yet" }),
5777
+ isLoading && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles16.loading, children: [
5778
+ /* @__PURE__ */ jsxRuntime.jsx(Spinner, { size: 16 }),
5779
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: "Loading conversations\u2026" })
5780
+ ] }),
5781
+ error && !isLoading && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles16.errorState, children: error }),
5782
+ !isLoading && !error && filtered.length === 0 && /* @__PURE__ */ jsxRuntime.jsx(EmptyState, { filter, hasQuery: query.trim().length > 0 }),
5230
5783
  filtered.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
5231
5784
  ConversationListItem,
5232
5785
  {
@@ -5239,95 +5792,178 @@ function ConversationList({
5239
5792
  ] })
5240
5793
  ] });
5241
5794
  }
5795
+ function TabButton({
5796
+ label,
5797
+ count,
5798
+ isActive,
5799
+ onClick
5800
+ }) {
5801
+ return /* @__PURE__ */ jsxRuntime.jsxs(
5802
+ "button",
5803
+ {
5804
+ type: "button",
5805
+ onClick,
5806
+ style: {
5807
+ ...styles16.tab,
5808
+ ...isActive ? styles16.tabActive : {}
5809
+ },
5810
+ children: [
5811
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: label }),
5812
+ /* @__PURE__ */ jsxRuntime.jsx(
5813
+ "span",
5814
+ {
5815
+ style: {
5816
+ ...styles16.tabCount,
5817
+ ...isActive ? styles16.tabCountActive : {}
5818
+ },
5819
+ children: count
5820
+ }
5821
+ )
5822
+ ]
5823
+ }
5824
+ );
5825
+ }
5826
+ function EmptyState({ filter, hasQuery }) {
5827
+ let title = "No conversations yet";
5828
+ let subtitle = "New chats will show up here as cases come in.";
5829
+ if (filter === "unread") {
5830
+ title = "No unread messages";
5831
+ subtitle = "You're all caught up.";
5832
+ } else if (hasQuery) {
5833
+ title = "No conversations match";
5834
+ subtitle = "Try a different search.";
5835
+ }
5836
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles16.empty, children: [
5837
+ /* @__PURE__ */ jsxRuntime.jsx(ChatIllustration, { size: 88 }),
5838
+ /* @__PURE__ */ jsxRuntime.jsx("p", { style: styles16.emptyTitle, children: title }),
5839
+ /* @__PURE__ */ jsxRuntime.jsx("p", { style: styles16.emptySubtitle, children: subtitle })
5840
+ ] });
5841
+ }
5242
5842
  var styles16 = {
5243
5843
  container: {
5244
5844
  display: "flex",
5245
5845
  flexDirection: "column",
5246
5846
  height: "100%",
5247
- backgroundColor: COLOR.white,
5248
- borderRight: `1px solid ${COLOR.neutral200}`
5847
+ backgroundColor: COLOR.white
5249
5848
  },
5250
- header: {
5251
- padding: SPACE.S3,
5849
+ // Tabs sit at the top of the panel now (header section removed).
5850
+ // A bit more top padding so they don't crowd the top edge.
5851
+ tabs: {
5852
+ display: "flex",
5853
+ gap: SPACE.S2,
5854
+ padding: `${SPACE.S5} ${SPACE.S5} ${SPACE.S2}`,
5252
5855
  borderBottom: `1px solid ${COLOR.neutral200}`,
5253
- backgroundColor: COLOR.neutral50,
5254
5856
  flexShrink: 0
5255
5857
  },
5256
- titleRow: {
5257
- display: "flex",
5858
+ tab: {
5859
+ display: "inline-flex",
5258
5860
  alignItems: "center",
5259
5861
  gap: SPACE.S2,
5260
- marginBottom: SPACE.S2
5261
- },
5262
- title: {
5263
- margin: 0,
5264
- fontSize: FONT_SIZE.md,
5862
+ padding: `${SPACE.S2} ${SPACE.S4}`,
5863
+ backgroundColor: "transparent",
5864
+ border: "none",
5865
+ borderRadius: RADIUS.lg,
5866
+ cursor: "pointer",
5867
+ color: COLOR.neutral500,
5868
+ fontFamily: "inherit",
5869
+ fontSize: FONT_SIZE.sm,
5265
5870
  fontWeight: FONT_WEIGHT.semibold,
5266
- color: COLOR.neutral900
5871
+ transition: "background-color 120ms ease, color 120ms ease"
5267
5872
  },
5268
- totalBadge: {
5269
- minWidth: "20px",
5270
- height: "20px",
5271
- padding: `0 ${SPACE.S2}`,
5873
+ tabActive: {
5874
+ backgroundColor: COLOR.primaryBg,
5875
+ color: COLOR.primary
5876
+ },
5877
+ tabCount: {
5272
5878
  fontSize: FONT_SIZE.xs,
5273
- fontWeight: FONT_WEIGHT.semibold,
5274
- color: COLOR.white,
5275
- backgroundColor: COLOR.primary,
5879
+ fontVariantNumeric: "tabular-nums",
5880
+ padding: "1px 8px",
5276
5881
  borderRadius: RADIUS.pill,
5882
+ backgroundColor: COLOR.neutral200,
5883
+ color: COLOR.neutral500,
5884
+ fontWeight: FONT_WEIGHT.semibold
5885
+ },
5886
+ tabCountActive: {
5887
+ backgroundColor: COLOR.primary,
5888
+ color: COLOR.white
5889
+ },
5890
+ search: {
5277
5891
  display: "flex",
5278
5892
  alignItems: "center",
5279
- justifyContent: "center"
5280
- },
5281
- searchRow: {
5282
- marginBottom: SPACE.S2
5893
+ gap: SPACE.S2,
5894
+ margin: `14px ${SPACE.S5} ${SPACE.S3}`,
5895
+ padding: `${SPACE.S3} 14px`,
5896
+ backgroundColor: COLOR.neutral100,
5897
+ border: `1.5px solid ${COLOR.neutral200}`,
5898
+ borderRadius: "14px",
5899
+ transition: "background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease"
5283
5900
  },
5284
- searchInput: {
5285
- width: "100%",
5286
- padding: `${SPACE.S2} ${SPACE.S3}`,
5287
- fontSize: FONT_SIZE.sm,
5288
- border: `1px solid ${COLOR.neutral200}`,
5289
- borderRadius: RADIUS.md,
5290
- outline: "none",
5291
- fontFamily: "inherit"
5901
+ searchFocused: {
5902
+ backgroundColor: COLOR.white,
5903
+ borderColor: COLOR.primary,
5904
+ boxShadow: `0 0 0 3px ${COLOR.primaryBg}`
5292
5905
  },
5293
- filterRow: {
5906
+ searchIcon: {
5294
5907
  display: "flex",
5295
- gap: SPACE.S1
5908
+ alignItems: "center",
5909
+ justifyContent: "center",
5910
+ flexShrink: 0
5296
5911
  },
5297
- filterButton: {
5912
+ searchInput: {
5298
5913
  flex: 1,
5299
- padding: `${SPACE.S1} ${SPACE.S2}`,
5300
- fontSize: FONT_SIZE.xs,
5301
- color: COLOR.neutral500,
5302
- backgroundColor: COLOR.white,
5303
- border: `1px solid ${COLOR.neutral200}`,
5304
- borderRadius: RADIUS.md,
5305
- cursor: "pointer"
5306
- },
5307
- filterButtonActive: {
5308
- color: COLOR.white,
5309
- backgroundColor: COLOR.primary,
5310
- borderColor: COLOR.primary,
5311
- fontWeight: FONT_WEIGHT.medium
5914
+ minWidth: 0,
5915
+ border: "none",
5916
+ background: "transparent",
5917
+ outline: "none",
5918
+ fontFamily: "inherit",
5919
+ fontSize: FONT_SIZE.md,
5920
+ color: COLOR.neutral900
5312
5921
  },
5313
5922
  list: {
5314
5923
  flex: 1,
5315
- overflowY: "auto"
5924
+ overflowY: "auto",
5925
+ padding: "4px 0 12px"
5316
5926
  },
5317
- state: {
5318
- padding: `${SPACE.S6} ${SPACE.S4}`,
5927
+ loading: {
5928
+ display: "flex",
5929
+ alignItems: "center",
5930
+ justifyContent: "center",
5931
+ gap: SPACE.S2,
5932
+ padding: `44px ${SPACE.S5}`,
5319
5933
  fontSize: FONT_SIZE.sm,
5320
- color: COLOR.neutral500,
5321
- textAlign: "center"
5934
+ color: COLOR.neutral500
5322
5935
  },
5323
5936
  errorState: {
5324
5937
  padding: SPACE.S4,
5938
+ margin: SPACE.S3,
5325
5939
  fontSize: FONT_SIZE.sm,
5326
5940
  color: COLOR.danger,
5327
5941
  backgroundColor: COLOR.dangerBg,
5328
- margin: SPACE.S3,
5942
+ border: `1px solid ${COLOR.dangerBorder}`,
5329
5943
  borderRadius: RADIUS.md,
5330
5944
  textAlign: "center"
5945
+ },
5946
+ empty: {
5947
+ display: "flex",
5948
+ flexDirection: "column",
5949
+ alignItems: "center",
5950
+ justifyContent: "center",
5951
+ padding: "44px 24px",
5952
+ textAlign: "center",
5953
+ color: COLOR.neutral500
5954
+ },
5955
+ emptyTitle: {
5956
+ margin: "12px 0 4px",
5957
+ fontSize: FONT_SIZE.md,
5958
+ fontWeight: FONT_WEIGHT.semibold,
5959
+ color: COLOR.neutral700
5960
+ },
5961
+ emptySubtitle: {
5962
+ margin: 0,
5963
+ fontSize: FONT_SIZE.sm,
5964
+ color: COLOR.neutral500,
5965
+ maxWidth: "280px",
5966
+ lineHeight: LINE_HEIGHT.normal
5331
5967
  }
5332
5968
  };
5333
5969
  ensureGlobalStyles();
@@ -5335,7 +5971,6 @@ var COMPACT_BREAKPOINT = 720;
5335
5971
  function CollabInbox({
5336
5972
  initialConversationId,
5337
5973
  onSelectConversation,
5338
- title,
5339
5974
  className,
5340
5975
  style
5341
5976
  }) {
@@ -5372,8 +6007,7 @@ function CollabInbox({
5372
6007
  selectedId,
5373
6008
  isLoading,
5374
6009
  error,
5375
- onSelect: handleSelect,
5376
- title
6010
+ onSelect: handleSelect
5377
6011
  }
5378
6012
  ) }),
5379
6013
  showDetail && /* @__PURE__ */ jsxRuntime.jsx("div", { style: isCompact ? styles17.mainCompact : styles17.main, children: selected ? /* @__PURE__ */ jsxRuntime.jsx(
@@ -5386,7 +6020,7 @@ function CollabInbox({
5386
6020
  onBack: isCompact ? () => setSelectedId(null) : void 0
5387
6021
  },
5388
6022
  selected.id
5389
- ) : /* @__PURE__ */ jsxRuntime.jsx(EmptyState, { hasAny: conversations.length > 0 }) })
6023
+ ) : /* @__PURE__ */ jsxRuntime.jsx(EmptyState2, { hasAny: conversations.length > 0 }) })
5390
6024
  ]
5391
6025
  }
5392
6026
  );
@@ -5420,7 +6054,7 @@ function parseChannelName(name, orderId) {
5420
6054
  orderId
5421
6055
  };
5422
6056
  }
5423
- function EmptyState({ hasAny }) {
6057
+ function EmptyState2({ hasAny }) {
5424
6058
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles17.empty, children: [
5425
6059
  /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles17.emptyIcon, children: "\u{1F4AC}" }),
5426
6060
  /* @__PURE__ */ jsxRuntime.jsx("h3", { style: styles17.emptyTitle, children: hasAny ? "Select a conversation" : "No conversations yet" }),
@@ -5826,7 +6460,10 @@ exports.ReplyPreview = ReplyPreview;
5826
6460
  exports.ReplyQuoteBlock = ReplyQuoteBlock;
5827
6461
  exports.SUPPORTED_IMAGE_TYPES = SUPPORTED_IMAGE_TYPES;
5828
6462
  exports.SeenByIndicator = SeenByIndicator;
6463
+ exports.THEME_DEFAULTS = THEME_DEFAULTS;
6464
+ exports.THEME_VAR = THEME_VAR;
5829
6465
  exports.TYPING_DEBOUNCE_MS = TYPING_DEBOUNCE_MS;
6466
+ exports.applyThemeOverrides = applyThemeOverrides;
5830
6467
  exports.useAudioRecorder = useAudioRecorder;
5831
6468
  exports.useChannelSettings = useChannelSettings;
5832
6469
  exports.useCollab = useCollab;