@natoe/colab 0.1.14 → 0.1.17

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.mjs CHANGED
@@ -57,6 +57,19 @@ function toCamelKey(key) {
57
57
  }
58
58
 
59
59
  // src/core/socket.ts
60
+ function normalizeUnreadPayload(payload) {
61
+ if (payload && typeof payload === "object") {
62
+ const obj = payload;
63
+ if (obj.conversation && typeof obj.conversation === "object") {
64
+ return {
65
+ conversation: obj.conversation,
66
+ order: obj.order && typeof obj.order === "object" ? obj.order : {}
67
+ };
68
+ }
69
+ return { conversation: payload, order: {} };
70
+ }
71
+ return { conversation: {}, order: {} };
72
+ }
60
73
  var CollabSocket = class {
61
74
  constructor() {
62
75
  this.socket = null;
@@ -122,7 +135,7 @@ var CollabSocket = class {
122
135
  if (!this.socket || !this.config) return;
123
136
  this.userChannel = this.socket.channel("user_notifications", {});
124
137
  this.userChannel.on("unread_update", (payload) => {
125
- this.onUnreadUpdate?.(payload);
138
+ this.onUnreadUpdate?.(normalizeUnreadPayload(payload));
126
139
  });
127
140
  this.userChannel.join().receive("ok", () => {
128
141
  }).receive("error", (reason) => {
@@ -133,7 +146,9 @@ var CollabSocket = class {
133
146
  });
134
147
  });
135
148
  }
136
- /** Register callback for unread count changes */
149
+ /** Register callback for unread count changes. The callback receives
150
+ * the normalised {@link UnreadCounts} shape regardless of which
151
+ * payload format the backend pushed. */
137
152
  onUnreadCountUpdate(callback) {
138
153
  this.onUnreadUpdate = callback;
139
154
  }
@@ -498,8 +513,6 @@ var SHADOW = {
498
513
  toast: "0 4px 12px rgba(0, 0, 0, 0.18)"
499
514
  };
500
515
  var Z_INDEX = {
501
- /** Sticky day divider — above bubbles, below interactive popovers. */
502
- sticky: 1,
503
516
  /** Toasts inside a panel. */
504
517
  toast: 20,
505
518
  /** Floating CollabPopup dialog. */
@@ -577,6 +590,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
577
590
  return new CollabSocket();
578
591
  });
579
592
  const [unreadCounts, setUnreadCounts] = useState({});
593
+ const [unreadCountsByOrder, setUnreadCountsByOrder] = useState({});
580
594
  const pendingOrderIds = useRef(/* @__PURE__ */ new Set());
581
595
  const pendingResolvers = useRef(/* @__PURE__ */ new Map());
582
596
  const previewCache = useRef(/* @__PURE__ */ new Map());
@@ -592,7 +606,8 @@ function CollabProvider({ config, apiBaseUrl, children }) {
592
606
  setSocket(s);
593
607
  }
594
608
  s.onUnreadCountUpdate((counts) => {
595
- setUnreadCounts(counts);
609
+ setUnreadCounts(counts.conversation);
610
+ setUnreadCountsByOrder(counts.order);
596
611
  previewCache.current.clear();
597
612
  });
598
613
  if (!s.isConnected()) {
@@ -800,6 +815,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
800
815
  apiBaseUrl,
801
816
  totalUnread,
802
817
  unreadCounts,
818
+ unreadCountsByOrder,
803
819
  requestPreview,
804
820
  invalidatePreview,
805
821
  fetchMessages,
@@ -1428,6 +1444,31 @@ function DicomIcon({ size = 18, color = "currentColor" }) {
1428
1444
  }
1429
1445
  );
1430
1446
  }
1447
+ function OpenCaseIcon({
1448
+ size = 18,
1449
+ color = "currentColor"
1450
+ }) {
1451
+ return /* @__PURE__ */ jsxs(
1452
+ "svg",
1453
+ {
1454
+ xmlns: "http://www.w3.org/2000/svg",
1455
+ width: size,
1456
+ height: size,
1457
+ viewBox: "0 0 24 24",
1458
+ fill: "none",
1459
+ stroke: color,
1460
+ strokeWidth: "2",
1461
+ strokeLinecap: "round",
1462
+ strokeLinejoin: "round",
1463
+ "aria-hidden": "true",
1464
+ children: [
1465
+ /* @__PURE__ */ jsx("path", { d: "M14 4h6v6" }),
1466
+ /* @__PURE__ */ jsx("path", { d: "M20 4L11 13" }),
1467
+ /* @__PURE__ */ jsx("path", { d: "M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5" })
1468
+ ]
1469
+ }
1470
+ );
1471
+ }
1431
1472
  function PeopleIcon({ size = 18, color = "currentColor" }) {
1432
1473
  return /* @__PURE__ */ jsxs(
1433
1474
  "svg",
@@ -1465,15 +1506,19 @@ function BackIcon({ size = 22, color = "currentColor" }) {
1465
1506
  }
1466
1507
  );
1467
1508
  }
1509
+ function stripOrderIdSuffix(name) {
1510
+ return name.replace(/\s*-\s*\d{1,6}\s*$/, "").trim();
1511
+ }
1468
1512
  function resolveDisplayName(patientData, displayName) {
1469
1513
  const localComplete = !!patientData.labName && !!patientData.displayOrderId;
1470
- if (localComplete) return buildChannelName(patientData);
1471
- return cleanChannelName(displayName) || buildChannelName(patientData);
1514
+ const raw = localComplete ? buildChannelName(patientData) : cleanChannelName(displayName) || buildChannelName(patientData);
1515
+ return stripOrderIdSuffix(raw);
1472
1516
  }
1473
1517
  function PatientHeader({
1474
1518
  patientData,
1475
1519
  participants,
1476
1520
  onOpenDicom,
1521
+ onOpenCase,
1477
1522
  onOpenSettings,
1478
1523
  onBack,
1479
1524
  hideName = false,
@@ -1481,29 +1526,58 @@ function PatientHeader({
1481
1526
  className
1482
1527
  }) {
1483
1528
  const hasDicom = !!(patientData.studyId && patientData.storageId);
1484
- const meta = [];
1485
- if (patientData.patientAge && patientData.patientSex) {
1486
- meta.push({
1487
- key: "ageSex",
1488
- label: "Age / Sex",
1489
- value: `${patientData.patientAge} \xB7 ${patientData.patientSex}`
1490
- });
1491
- } else if (patientData.patientAge) {
1492
- meta.push({ key: "age", label: "Age", value: String(patientData.patientAge) });
1493
- } else if (patientData.patientSex) {
1494
- meta.push({ key: "sex", label: "Sex", value: String(patientData.patientSex) });
1495
- }
1496
- if (patientData.studyType) {
1497
- meta.push({ key: "modality", label: "Modality", value: patientData.studyType });
1498
- }
1529
+ const resolvedName = resolveDisplayName(patientData, displayName);
1530
+ const metaParts = [];
1531
+ if (patientData.patientAge) metaParts.push(String(patientData.patientAge));
1532
+ if (patientData.patientSex) metaParts.push(String(patientData.patientSex));
1533
+ if (patientData.studyType) metaParts.push(patientData.studyType);
1499
1534
  if (patientData.bodyParts && patientData.bodyParts.length > 0) {
1500
- meta.push({
1501
- key: "body",
1502
- label: "Body part",
1503
- value: patientData.bodyParts.join(", ")
1504
- });
1535
+ metaParts.push(patientData.bodyParts.join(", "));
1505
1536
  }
1506
- const hasActions = hasDicom && onOpenDicom || onOpenSettings;
1537
+ const hasActions = hasDicom && onOpenDicom || onOpenCase || onOpenSettings;
1538
+ const actions = hasActions ? /* @__PURE__ */ jsxs("div", { style: styles.actions, children: [
1539
+ hasDicom && onOpenDicom && /* @__PURE__ */ jsxs(
1540
+ "button",
1541
+ {
1542
+ onClick: onOpenDicom,
1543
+ style: styles.dicomButton,
1544
+ type: "button",
1545
+ "aria-label": "View DICOM study",
1546
+ children: [
1547
+ /* @__PURE__ */ jsx(DicomIcon, { size: 18, color: COLOR.primary }),
1548
+ /* @__PURE__ */ jsx("span", { children: "View DICOM" })
1549
+ ]
1550
+ }
1551
+ ),
1552
+ onOpenCase && /* @__PURE__ */ jsxs(
1553
+ "button",
1554
+ {
1555
+ onClick: onOpenCase,
1556
+ style: styles.openCaseButton,
1557
+ type: "button",
1558
+ "aria-label": "Open case",
1559
+ title: "Open case",
1560
+ children: [
1561
+ /* @__PURE__ */ jsx(OpenCaseIcon, { size: 18, color: COLOR.primary }),
1562
+ /* @__PURE__ */ jsx("span", { children: "Open" })
1563
+ ]
1564
+ }
1565
+ ),
1566
+ onOpenSettings && /* @__PURE__ */ jsxs(
1567
+ "button",
1568
+ {
1569
+ onClick: onOpenSettings,
1570
+ style: styles.settingsIconButton,
1571
+ type: "button",
1572
+ "aria-label": `Open channel settings (${participants.length} participants)`,
1573
+ title: "Channel participants",
1574
+ children: [
1575
+ /* @__PURE__ */ jsx(PeopleIcon, { size: 18, color: COLOR.neutral600 }),
1576
+ /* @__PURE__ */ jsx("span", { style: styles.participantCount, children: participants.length })
1577
+ ]
1578
+ }
1579
+ )
1580
+ ] }) : null;
1507
1581
  return /* @__PURE__ */ jsxs("div", { className, style: styles.container, children: [
1508
1582
  !hideName && /* @__PURE__ */ jsxs("div", { style: styles.nameRow, children: [
1509
1583
  onBack && /* @__PURE__ */ jsx(
@@ -1514,46 +1588,14 @@ function PatientHeader({
1514
1588
  style: styles.backButton,
1515
1589
  "aria-label": "Back to conversations",
1516
1590
  title: "Back to conversations",
1517
- children: /* @__PURE__ */ jsx(BackIcon, { size: 22, color: COLOR.neutral800 })
1591
+ children: /* @__PURE__ */ jsx(BackIcon, { size: 20, color: COLOR.neutral800 })
1518
1592
  }
1519
1593
  ),
1520
- /* @__PURE__ */ jsx("span", { style: styles.nameText, children: resolveDisplayName(patientData, displayName) })
1594
+ /* @__PURE__ */ jsx("span", { style: styles.nameText, children: resolvedName }),
1595
+ actions
1521
1596
  ] }),
1522
- /* @__PURE__ */ jsxs("div", { style: styles.caseCard, children: [
1523
- meta.length > 0 && /* @__PURE__ */ jsx("div", { style: styles.metaRow, children: meta.map((item) => /* @__PURE__ */ jsxs("div", { style: styles.metaCol, children: [
1524
- /* @__PURE__ */ jsx("span", { style: styles.metaLabel, children: item.label }),
1525
- /* @__PURE__ */ jsx("span", { style: styles.metaValue, children: item.value })
1526
- ] }, item.key)) }),
1527
- hasActions && /* @__PURE__ */ jsxs("div", { style: styles.actions, children: [
1528
- hasDicom && onOpenDicom && /* @__PURE__ */ jsxs(
1529
- "button",
1530
- {
1531
- onClick: onOpenDicom,
1532
- style: styles.dicomButton,
1533
- type: "button",
1534
- "aria-label": "View DICOM study",
1535
- children: [
1536
- /* @__PURE__ */ jsx(DicomIcon, { size: 18, color: COLOR.primary }),
1537
- /* @__PURE__ */ jsx("span", { children: "View DICOM" })
1538
- ]
1539
- }
1540
- ),
1541
- onOpenSettings && /* @__PURE__ */ jsxs(
1542
- "button",
1543
- {
1544
- onClick: onOpenSettings,
1545
- style: styles.settingsIconButton,
1546
- type: "button",
1547
- "aria-label": `Open channel settings (${participants.length} participants)`,
1548
- title: "Channel participants",
1549
- children: [
1550
- /* @__PURE__ */ jsx(PeopleIcon, { size: 18, color: COLOR.neutral600 }),
1551
- /* @__PURE__ */ jsx("span", { style: styles.participantCount, children: participants.length })
1552
- ]
1553
- }
1554
- )
1555
- ] })
1556
- ] })
1597
+ !hideName && metaParts.length > 0 && /* @__PURE__ */ jsx("div", { style: styles.metaRow, children: /* @__PURE__ */ jsx("span", { style: styles.metaText, children: metaParts.join(" \xB7 ") }) }),
1598
+ hideName && actions && /* @__PURE__ */ jsx("div", { style: styles.actionsOnlyRow, children: actions })
1557
1599
  ] });
1558
1600
  }
1559
1601
  var styles = {
@@ -1561,16 +1603,16 @@ var styles = {
1561
1603
  display: "flex",
1562
1604
  flexDirection: "column",
1563
1605
  backgroundColor: COLOR.primaryBg,
1564
- borderBottom: `1px solid ${COLOR.neutral200}`
1606
+ borderBottom: `1px solid ${COLOR.neutral200}`,
1607
+ padding: `${SPACE.S2} ${SPACE.S4}`,
1608
+ gap: "2px"
1565
1609
  },
1566
- // Optional name row, only when `hideName` is false (compact-inbox path)
1610
+ // Title row back · name · actions in one horizontal line.
1567
1611
  nameRow: {
1568
1612
  display: "flex",
1569
1613
  alignItems: "center",
1570
1614
  gap: SPACE.S2,
1571
- padding: `${SPACE.S3} ${SPACE.S4} ${SPACE.S2}`,
1572
- borderBottom: `1px solid ${COLOR.neutral200}`,
1573
- backgroundColor: COLOR.white
1615
+ minWidth: 0
1574
1616
  },
1575
1617
  backButton: {
1576
1618
  width: SIZE.controlIcon,
@@ -1586,57 +1628,45 @@ var styles = {
1586
1628
  flexShrink: 0
1587
1629
  },
1588
1630
  nameText: {
1631
+ flex: 1,
1632
+ minWidth: 0,
1589
1633
  fontSize: FONT_SIZE.lg,
1590
1634
  fontWeight: FONT_WEIGHT.bold,
1591
1635
  color: COLOR.neutral900,
1592
1636
  lineHeight: LINE_HEIGHT.tight,
1593
1637
  letterSpacing: "-0.01em",
1594
- minWidth: 0,
1595
1638
  overflow: "hidden",
1596
1639
  textOverflow: "ellipsis",
1597
1640
  whiteSpace: "nowrap"
1598
1641
  },
1599
- // Case card: meta columns flowing in a wrap-row, actions stacked
1600
- // below. Each meta column is a small label-above-value pair so the
1601
- // user can scan field names quickly without a legend.
1602
- caseCard: {
1603
- display: "flex",
1604
- flexDirection: "column",
1605
- gap: SPACE.S3,
1606
- padding: `${SPACE.S3} ${SPACE.S5}`
1607
- },
1608
- // Wrap-row of stacked label/value columns. Compact column gap keeps
1609
- // the row dense without crowding; row gap kicks in when columns
1610
- // wrap to a second line on narrow popups.
1642
+ // Sub-row beneath the title small muted meta. Aligned under the
1643
+ // name (past the back button) so it visually anchors to the title
1644
+ // rather than the panel edge.
1611
1645
  metaRow: {
1612
1646
  display: "flex",
1613
- flexWrap: "wrap",
1614
- columnGap: SPACE.S5,
1615
- rowGap: SPACE.S2,
1647
+ alignItems: "center",
1648
+ paddingLeft: `calc(${SIZE.controlIcon} + ${SPACE.S2} - ${SPACE.S2})`,
1616
1649
  minWidth: 0
1617
1650
  },
1618
- metaCol: {
1651
+ // When hideName=true (popup mode), only the actions render. The
1652
+ // popup's own title bar carries the name + meta above this row, so
1653
+ // the actions land flush-left here — pinning them to the right
1654
+ // edge would leave the whole left half of the strip awkwardly
1655
+ // empty (the meta that used to fill it now lives in the title bar).
1656
+ actionsOnlyRow: {
1619
1657
  display: "flex",
1620
- flexDirection: "column",
1621
- gap: "2px",
1658
+ justifyContent: "flex-start",
1622
1659
  minWidth: 0
1623
1660
  },
1624
- metaLabel: {
1625
- fontSize: "11px",
1626
- fontWeight: FONT_WEIGHT.bold,
1627
- letterSpacing: "0.06em",
1628
- textTransform: "uppercase",
1629
- color: COLOR.neutral500,
1630
- whiteSpace: "nowrap"
1631
- },
1632
- metaValue: {
1661
+ metaText: {
1633
1662
  fontSize: FONT_SIZE.sm,
1634
- fontWeight: FONT_WEIGHT.semibold,
1635
- color: COLOR.neutral900,
1636
- whiteSpace: "nowrap",
1663
+ fontWeight: FONT_WEIGHT.medium,
1664
+ color: COLOR.neutral600,
1665
+ lineHeight: LINE_HEIGHT.tight,
1637
1666
  overflow: "hidden",
1638
1667
  textOverflow: "ellipsis",
1639
- maxWidth: "180px"
1668
+ whiteSpace: "nowrap",
1669
+ minWidth: 0
1640
1670
  },
1641
1671
  actions: {
1642
1672
  display: "flex",
@@ -1648,7 +1678,7 @@ var styles = {
1648
1678
  alignItems: "center",
1649
1679
  gap: SPACE.S2,
1650
1680
  minHeight: SIZE.control,
1651
- padding: `${SPACE.S2} 14px`,
1681
+ padding: `${SPACE.S1} ${SPACE.S3}`,
1652
1682
  fontSize: FONT_SIZE.sm,
1653
1683
  fontWeight: FONT_WEIGHT.semibold,
1654
1684
  color: COLOR.primary,
@@ -1657,10 +1687,25 @@ var styles = {
1657
1687
  borderRadius: RADIUS.lg,
1658
1688
  cursor: "pointer"
1659
1689
  },
1660
- // Channel-info button: people icon + participant count. Click opens the
1661
- // channel settings overlay (kept on the same handler as before so hosts
1662
- // don't have to re-wire — the affordance just looks like a "members"
1663
- // pill now instead of a gear).
1690
+ // Sibling of the DICOM button same shape so the two read as a pair.
1691
+ // Click hands off to the host's `onOpenCase` callback (role-aware
1692
+ // routing lives on the host side).
1693
+ openCaseButton: {
1694
+ display: "inline-flex",
1695
+ alignItems: "center",
1696
+ gap: SPACE.S2,
1697
+ minHeight: SIZE.control,
1698
+ padding: `${SPACE.S1} ${SPACE.S3}`,
1699
+ fontSize: FONT_SIZE.sm,
1700
+ fontWeight: FONT_WEIGHT.semibold,
1701
+ color: COLOR.primary,
1702
+ backgroundColor: COLOR.white,
1703
+ border: `1.5px solid ${COLOR.primary}`,
1704
+ borderRadius: RADIUS.lg,
1705
+ cursor: "pointer"
1706
+ },
1707
+ // Channel-info button: people icon + participant count. Opens the
1708
+ // channel settings overlay.
1664
1709
  settingsIconButton: {
1665
1710
  minHeight: SIZE.control,
1666
1711
  display: "inline-flex",
@@ -2140,6 +2185,62 @@ function AlertIcon({ size = 14, color = "currentColor" }) {
2140
2185
  }
2141
2186
  );
2142
2187
  }
2188
+ function RoleAvatarIcon({
2189
+ role,
2190
+ size = 18,
2191
+ color = "currentColor",
2192
+ className
2193
+ }) {
2194
+ const common = {
2195
+ width: size,
2196
+ height: size,
2197
+ viewBox: "0 0 24 24",
2198
+ fill: "none",
2199
+ stroke: color,
2200
+ strokeWidth: 1.9,
2201
+ strokeLinecap: "round",
2202
+ strokeLinejoin: "round",
2203
+ "aria-hidden": true,
2204
+ className
2205
+ };
2206
+ switch (role) {
2207
+ case "radiologist":
2208
+ return /* @__PURE__ */ jsxs("svg", { ...common, children: [
2209
+ /* @__PURE__ */ jsx("path", { d: "M7.5 8.5h9" }),
2210
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "9.5", r: "4" }),
2211
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "7.5", r: "1", fill: color, stroke: "none" }),
2212
+ /* @__PURE__ */ jsx("path", { d: "M4 21v-1.5a5 5 0 0 1 5-5h6a5 5 0 0 1 5 5V21" })
2213
+ ] });
2214
+ case "lab":
2215
+ return /* @__PURE__ */ jsxs("svg", { ...common, children: [
2216
+ /* @__PURE__ */ jsx("path", { d: "M3 21V9l9-6 9 6v12" }),
2217
+ /* @__PURE__ */ jsx("path", { d: "M12 11v6" }),
2218
+ /* @__PURE__ */ jsx("path", { d: "M9 14h6" }),
2219
+ /* @__PURE__ */ jsx("path", { d: "M11 21v-3h2v3" }),
2220
+ /* @__PURE__ */ jsx("path", { d: "M3 21h18" })
2221
+ ] });
2222
+ case "physician":
2223
+ return /* @__PURE__ */ jsxs("svg", { ...common, children: [
2224
+ /* @__PURE__ */ jsx("path", { d: "M7 3v5a4 4 0 0 0 8 0V3" }),
2225
+ /* @__PURE__ */ jsx("path", { d: "M5 3h2" }),
2226
+ /* @__PURE__ */ jsx("path", { d: "M15 3h2" }),
2227
+ /* @__PURE__ */ jsx("path", { d: "M11 12v3a5 5 0 0 0 5 5h0a5 5 0 0 0 5-5v-2" }),
2228
+ /* @__PURE__ */ jsx("circle", { cx: "21", cy: "11", r: "2" })
2229
+ ] });
2230
+ case "admin":
2231
+ return /* @__PURE__ */ jsxs("svg", { ...common, children: [
2232
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "3" }),
2233
+ /* @__PURE__ */ jsx("path", { d: "M12 2v3" }),
2234
+ /* @__PURE__ */ jsx("path", { d: "M12 19v3" }),
2235
+ /* @__PURE__ */ jsx("path", { d: "M5 12H2" }),
2236
+ /* @__PURE__ */ jsx("path", { d: "M22 12h-3" }),
2237
+ /* @__PURE__ */ jsx("path", { d: "M5.6 5.6l2 2" }),
2238
+ /* @__PURE__ */ jsx("path", { d: "M16.4 16.4l2 2" }),
2239
+ /* @__PURE__ */ jsx("path", { d: "M5.6 18.4l2-2" }),
2240
+ /* @__PURE__ */ jsx("path", { d: "M16.4 7.6l2-2" })
2241
+ ] });
2242
+ }
2243
+ }
2143
2244
  function Spinner({ size = 16, color = COLOR.neutral500, thickness = 2 }) {
2144
2245
  return /* @__PURE__ */ jsx(
2145
2246
  "span",
@@ -2228,12 +2329,11 @@ function MessageBubble({
2228
2329
  ...styles5.authorAvatar,
2229
2330
  backgroundColor: roleColor(message.senderRole)
2230
2331
  },
2231
- "aria-hidden": "true",
2232
- children: computeInitials(message.senderName)
2332
+ "aria-label": ROLE_LABELS[message.senderRole] ?? message.senderRole,
2333
+ children: /* @__PURE__ */ jsx(RoleAvatarIcon, { role: message.senderRole, size: 20, color: COLOR.white })
2233
2334
  }
2234
2335
  ),
2235
- /* @__PURE__ */ jsx("span", { style: styles5.authorName, children: message.senderName }),
2236
- /* @__PURE__ */ jsx(RoleBadge, { role: message.senderRole })
2336
+ /* @__PURE__ */ jsx("span", { style: styles5.authorName, children: message.senderName })
2237
2337
  ] }),
2238
2338
  /* @__PURE__ */ jsxs(
2239
2339
  "div",
@@ -2308,9 +2408,6 @@ function MessageBubble({
2308
2408
  }
2309
2409
  );
2310
2410
  }
2311
- function computeInitials(name) {
2312
- return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]?.toUpperCase() ?? "").join("") || "?";
2313
- }
2314
2411
  function roleColor(role) {
2315
2412
  switch (role) {
2316
2413
  case "radiologist":
@@ -2395,9 +2492,6 @@ function SystemBubble({ message }) {
2395
2492
  /* @__PURE__ */ jsx("span", { style: styles5.systemTime, children: formatTime(message.insertedAt) })
2396
2493
  ] }) });
2397
2494
  }
2398
- function RoleBadge({ role }) {
2399
- return /* @__PURE__ */ jsx("span", { style: { ...styles5.roleBadge, backgroundColor: roleColor(role) }, children: ROLE_LABELS[role] ?? role });
2400
- }
2401
2495
  function formatTime(iso) {
2402
2496
  try {
2403
2497
  return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
@@ -2499,16 +2593,6 @@ var styles5 = {
2499
2593
  color: "#fff4d6",
2500
2594
  backgroundColor: "rgba(255, 255, 255, 0.2)"
2501
2595
  },
2502
- roleBadge: {
2503
- fontSize: "10px",
2504
- fontWeight: FONT_WEIGHT.bold,
2505
- padding: "2px 7px",
2506
- borderRadius: RADIUS.sm,
2507
- textTransform: "uppercase",
2508
- letterSpacing: "0.06em",
2509
- color: COLOR.white,
2510
- flexShrink: 0
2511
- },
2512
2596
  textBody: {
2513
2597
  margin: 0,
2514
2598
  fontSize: FONT_SIZE.md,
@@ -2972,9 +3056,6 @@ var styles6 = {
2972
3056
  alignItems: "center",
2973
3057
  gap: SPACE.S3,
2974
3058
  margin: `${SPACE.S4} 0 ${SPACE.S3}`,
2975
- position: "sticky",
2976
- top: SPACE.S1,
2977
- zIndex: Z_INDEX.sticky,
2978
3059
  pointerEvents: "none"
2979
3060
  },
2980
3061
  dayHr: {
@@ -2989,11 +3070,7 @@ var styles6 = {
2989
3070
  color: COLOR.neutral500,
2990
3071
  textTransform: "uppercase",
2991
3072
  letterSpacing: "0.06em",
2992
- // Subtle pill background so the label is readable when it overlays
2993
- // bubbles passing under it during sticky scroll. Without this the
2994
- // hairlines visually run through the text on tinted backgrounds.
2995
3073
  padding: `2px ${SPACE.S2}`,
2996
- backgroundColor: COLOR.white,
2997
3074
  borderRadius: RADIUS.sm
2998
3075
  },
2999
3076
  bubbleSlot: {
@@ -3291,7 +3368,7 @@ function MessageInput({
3291
3368
  style: styles8.textarea
3292
3369
  }
3293
3370
  ),
3294
- /* @__PURE__ */ jsxs(
3371
+ /* @__PURE__ */ jsx(
3295
3372
  "button",
3296
3373
  {
3297
3374
  onClick: handleSendText,
@@ -3303,10 +3380,7 @@ function MessageInput({
3303
3380
  opacity: text.trim() ? 1 : 0.5
3304
3381
  },
3305
3382
  type: "button",
3306
- children: [
3307
- /* @__PURE__ */ jsx(SendIcon, { size: 20, color: COLOR.white }),
3308
- /* @__PURE__ */ jsx("span", { style: styles8.sendLabel, children: "Send" })
3309
- ]
3383
+ children: /* @__PURE__ */ jsx(SendIcon, { size: 20, color: COLOR.white })
3310
3384
  }
3311
3385
  )
3312
3386
  ] })
@@ -3404,29 +3478,33 @@ var styles8 = {
3404
3478
  fontFamily: "inherit",
3405
3479
  transition: "background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease"
3406
3480
  },
3407
- // Pill-shaped send: icon + "Send" label. Always labelled (never icon-
3408
- // only) so the affordance is obvious to first-time users.
3481
+ // Square icon-only send button. The arrow inside an obvious input
3482
+ // context is affordance enough; the word "Send" used to sit next to
3483
+ // it but added clutter without disambiguating anything.
3409
3484
  sendButton: {
3410
3485
  display: "inline-flex",
3411
3486
  alignItems: "center",
3412
- gap: "6px",
3413
- minWidth: "48px",
3487
+ justifyContent: "center",
3488
+ width: "48px",
3414
3489
  height: "48px",
3415
- padding: `0 ${SPACE.S4}`,
3416
3490
  backgroundColor: COLOR.primary,
3417
3491
  color: COLOR.white,
3418
3492
  border: "none",
3419
3493
  borderRadius: "14px",
3420
3494
  cursor: "pointer",
3421
3495
  flexShrink: 0,
3422
- fontFamily: "inherit",
3423
- fontSize: "15px",
3424
- fontWeight: FONT_WEIGHT.semibold,
3425
3496
  transition: "opacity 120ms ease, background-color 120ms ease"
3426
- },
3427
- sendLabel: {
3428
- lineHeight: 1
3429
3497
  }};
3498
+ var ROLE_AVATAR_BG = {
3499
+ radiologist: "#4f46e5",
3500
+ // indigo
3501
+ lab: "#2563eb",
3502
+ // brand blue
3503
+ physician: "#0284c7",
3504
+ // sky
3505
+ admin: "#64748b"
3506
+ // slate
3507
+ };
3430
3508
  function ParticipantsList({
3431
3509
  participants,
3432
3510
  currentUserId,
@@ -3436,7 +3514,17 @@ function ParticipantsList({
3436
3514
  }) {
3437
3515
  return /* @__PURE__ */ jsx("div", { className, style: styles9.list, children: participants.map((participant) => /* @__PURE__ */ jsxs("div", { style: styles9.item, children: [
3438
3516
  /* @__PURE__ */ jsxs("div", { style: styles9.avatar, children: [
3439
- participant.avatar ? /* @__PURE__ */ jsx("img", { src: participant.avatar, alt: "", style: styles9.avatarImg }) : /* @__PURE__ */ jsx("span", { style: styles9.avatarFallback, children: (participant.userName ?? "?").charAt(0).toUpperCase() }),
3517
+ participant.avatar ? /* @__PURE__ */ jsx("img", { src: participant.avatar, alt: "", style: styles9.avatarImg }) : /* @__PURE__ */ jsx(
3518
+ "span",
3519
+ {
3520
+ style: {
3521
+ ...styles9.avatarFallback,
3522
+ backgroundColor: ROLE_AVATAR_BG[participant.userRole]
3523
+ },
3524
+ "aria-hidden": "true",
3525
+ children: /* @__PURE__ */ jsx(RoleAvatarIcon, { role: participant.userRole, size: 18, color: COLOR.white })
3526
+ }
3527
+ ),
3440
3528
  /* @__PURE__ */ jsx(
3441
3529
  "span",
3442
3530
  {
@@ -3452,7 +3540,7 @@ function ParticipantsList({
3452
3540
  participant.userName ?? fallbackName(participant.userRole),
3453
3541
  participant.userId === currentUserId && /* @__PURE__ */ jsx("span", { style: styles9.youBadge, children: " (you)" })
3454
3542
  ] }),
3455
- /* @__PURE__ */ jsx(RoleBadge2, { role: participant.userRole })
3543
+ /* @__PURE__ */ jsx(RoleBadge, { role: participant.userRole })
3456
3544
  ] }),
3457
3545
  isAdmin && participant.userId !== currentUserId && /* @__PURE__ */ jsx(
3458
3546
  "button",
@@ -3480,7 +3568,7 @@ function fallbackName(role) {
3480
3568
  return "User";
3481
3569
  }
3482
3570
  }
3483
- function RoleBadge2({ role }) {
3571
+ function RoleBadge({ role }) {
3484
3572
  const colors = {
3485
3573
  radiologist: { bg: "#eef2ff", text: "#4f46e5" },
3486
3574
  // indigo
@@ -3522,13 +3610,10 @@ var styles9 = {
3522
3610
  width: "32px",
3523
3611
  height: "32px",
3524
3612
  borderRadius: RADIUS.full,
3525
- backgroundColor: COLOR.neutral200,
3526
3613
  display: "flex",
3527
3614
  alignItems: "center",
3528
3615
  justifyContent: "center",
3529
- fontSize: FONT_SIZE.sm,
3530
- fontWeight: FONT_WEIGHT.semibold,
3531
- color: COLOR.neutral500
3616
+ color: COLOR.white
3532
3617
  },
3533
3618
  statusDot: {
3534
3619
  position: "absolute",
@@ -4120,6 +4205,7 @@ function CollabPanel({
4120
4205
  showSeenBy = true,
4121
4206
  onBack,
4122
4207
  hidePatientName = false,
4208
+ hideOpenCase = false,
4123
4209
  onConversationChange,
4124
4210
  themeMode = "light",
4125
4211
  className,
@@ -4162,6 +4248,7 @@ function CollabPanel({
4162
4248
  config.onOpenDicom(patientData.studyId, patientData.storageId);
4163
4249
  }
4164
4250
  };
4251
+ const handleOpenCase = !hideOpenCase && config.onOpenCase ? () => config.onOpenCase?.(patientData.orderId, patientData.displayOrderId) : void 0;
4165
4252
  const handleJumpToMessage = (messageId) => {
4166
4253
  messageListRef.current?.scrollToMessage(messageId);
4167
4254
  };
@@ -4237,6 +4324,7 @@ function CollabPanel({
4237
4324
  patientData,
4238
4325
  participants,
4239
4326
  onOpenDicom: handleOpenDicom,
4327
+ onOpenCase: handleOpenCase,
4240
4328
  onOpenSettings: () => setShowSettings(true),
4241
4329
  onBack,
4242
4330
  hideName: hidePatientName,
@@ -4491,14 +4579,16 @@ function CollabPopup({
4491
4579
  ),
4492
4580
  /* @__PURE__ */ jsxs("div", { style: styles13.titleCenter, children: [
4493
4581
  /* @__PURE__ */ jsx("h2", { id: titleId, style: styles13.patientName, children: patientData.patientName || titleText }),
4494
- (patientData.labName || patientData.displayOrderId) && /* @__PURE__ */ jsxs("div", { style: styles13.subRow, children: [
4495
- patientData.labName && /* @__PURE__ */ jsx("span", { children: patientData.labName }),
4496
- patientData.labName && patientData.displayOrderId && /* @__PURE__ */ jsx("span", { style: styles13.subDot, "aria-hidden": "true", children: "\xB7" }),
4497
- patientData.displayOrderId && /* @__PURE__ */ jsxs("span", { style: styles13.mono, children: [
4498
- "#",
4499
- patientData.displayOrderId.slice(-4)
4500
- ] })
4501
- ] })
4582
+ (() => {
4583
+ const parts = [];
4584
+ if (patientData.patientAge) parts.push(String(patientData.patientAge));
4585
+ if (patientData.patientSex) parts.push(String(patientData.patientSex));
4586
+ if (patientData.studyType) parts.push(patientData.studyType);
4587
+ if (patientData.bodyParts && patientData.bodyParts.length > 0) {
4588
+ parts.push(patientData.bodyParts.join(", "));
4589
+ }
4590
+ return parts.length > 0 ? /* @__PURE__ */ jsx("div", { style: styles13.subRow, children: /* @__PURE__ */ jsx("span", { style: styles13.metaText, children: parts.join(" \xB7 ") }) }) : null;
4591
+ })()
4502
4592
  ] }),
4503
4593
  /* @__PURE__ */ jsxs("div", { style: styles13.titleActions, children: [
4504
4594
  /* @__PURE__ */ jsx(
@@ -4587,19 +4677,17 @@ var styles13 = {
4587
4677
  subRow: {
4588
4678
  display: "flex",
4589
4679
  alignItems: "center",
4590
- gap: SPACE.S2,
4591
4680
  fontSize: FONT_SIZE.sm,
4592
4681
  color: COLOR.neutral500,
4593
4682
  overflow: "hidden",
4594
4683
  textOverflow: "ellipsis",
4595
4684
  whiteSpace: "nowrap"
4596
4685
  },
4597
- subDot: {
4598
- color: COLOR.neutral400
4599
- },
4600
- mono: {
4601
- fontFamily: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
4602
- fontVariantNumeric: "tabular-nums"
4686
+ metaText: {
4687
+ overflow: "hidden",
4688
+ textOverflow: "ellipsis",
4689
+ whiteSpace: "nowrap",
4690
+ minWidth: 0
4603
4691
  },
4604
4692
  titleActions: {
4605
4693
  display: "flex",
@@ -5412,7 +5500,7 @@ function ConversationListItem({
5412
5500
  const studyType = item.patientSnapshot?.studyType;
5413
5501
  const patientId = item.patientSnapshot?.patientId;
5414
5502
  const showStudyRow = !!(studyType || patientId);
5415
- const initials = computeInitials2(item.patientSnapshot?.patientName || displayName);
5503
+ const initials = computeInitials(item.patientSnapshot?.patientName || displayName);
5416
5504
  return /* @__PURE__ */ jsxs(
5417
5505
  "button",
5418
5506
  {
@@ -5507,7 +5595,7 @@ function previewText(message) {
5507
5595
  }
5508
5596
  }
5509
5597
  }
5510
- function computeInitials2(name) {
5598
+ function computeInitials(name) {
5511
5599
  return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() ?? "").join("") || "?";
5512
5600
  }
5513
5601
  var styles15 = {
@@ -6321,7 +6409,7 @@ function useUnreadCount() {
6321
6409
  const [counts, setCounts] = useState({});
6322
6410
  useEffect(() => {
6323
6411
  socket.onUnreadCountUpdate((serverCounts) => {
6324
- setCounts(serverCounts);
6412
+ setCounts(serverCounts.conversation);
6325
6413
  });
6326
6414
  }, [socket]);
6327
6415
  const getCountForConversation = useCallback(