@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.js CHANGED
@@ -63,6 +63,19 @@ function toCamelKey(key) {
63
63
  }
64
64
 
65
65
  // src/core/socket.ts
66
+ function normalizeUnreadPayload(payload) {
67
+ if (payload && typeof payload === "object") {
68
+ const obj = payload;
69
+ if (obj.conversation && typeof obj.conversation === "object") {
70
+ return {
71
+ conversation: obj.conversation,
72
+ order: obj.order && typeof obj.order === "object" ? obj.order : {}
73
+ };
74
+ }
75
+ return { conversation: payload, order: {} };
76
+ }
77
+ return { conversation: {}, order: {} };
78
+ }
66
79
  var CollabSocket = class {
67
80
  constructor() {
68
81
  this.socket = null;
@@ -128,7 +141,7 @@ var CollabSocket = class {
128
141
  if (!this.socket || !this.config) return;
129
142
  this.userChannel = this.socket.channel("user_notifications", {});
130
143
  this.userChannel.on("unread_update", (payload) => {
131
- this.onUnreadUpdate?.(payload);
144
+ this.onUnreadUpdate?.(normalizeUnreadPayload(payload));
132
145
  });
133
146
  this.userChannel.join().receive("ok", () => {
134
147
  }).receive("error", (reason) => {
@@ -139,7 +152,9 @@ var CollabSocket = class {
139
152
  });
140
153
  });
141
154
  }
142
- /** Register callback for unread count changes */
155
+ /** Register callback for unread count changes. The callback receives
156
+ * the normalised {@link UnreadCounts} shape regardless of which
157
+ * payload format the backend pushed. */
143
158
  onUnreadCountUpdate(callback) {
144
159
  this.onUnreadUpdate = callback;
145
160
  }
@@ -504,8 +519,6 @@ var SHADOW = {
504
519
  toast: "0 4px 12px rgba(0, 0, 0, 0.18)"
505
520
  };
506
521
  var Z_INDEX = {
507
- /** Sticky day divider — above bubbles, below interactive popovers. */
508
- sticky: 1,
509
522
  /** Toasts inside a panel. */
510
523
  toast: 20,
511
524
  /** Floating CollabPopup dialog. */
@@ -583,6 +596,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
583
596
  return new CollabSocket();
584
597
  });
585
598
  const [unreadCounts, setUnreadCounts] = React4.useState({});
599
+ const [unreadCountsByOrder, setUnreadCountsByOrder] = React4.useState({});
586
600
  const pendingOrderIds = React4.useRef(/* @__PURE__ */ new Set());
587
601
  const pendingResolvers = React4.useRef(/* @__PURE__ */ new Map());
588
602
  const previewCache = React4.useRef(/* @__PURE__ */ new Map());
@@ -598,7 +612,8 @@ function CollabProvider({ config, apiBaseUrl, children }) {
598
612
  setSocket(s);
599
613
  }
600
614
  s.onUnreadCountUpdate((counts) => {
601
- setUnreadCounts(counts);
615
+ setUnreadCounts(counts.conversation);
616
+ setUnreadCountsByOrder(counts.order);
602
617
  previewCache.current.clear();
603
618
  });
604
619
  if (!s.isConnected()) {
@@ -806,6 +821,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
806
821
  apiBaseUrl,
807
822
  totalUnread,
808
823
  unreadCounts,
824
+ unreadCountsByOrder,
809
825
  requestPreview,
810
826
  invalidatePreview,
811
827
  fetchMessages,
@@ -1434,6 +1450,31 @@ function DicomIcon({ size = 18, color = "currentColor" }) {
1434
1450
  }
1435
1451
  );
1436
1452
  }
1453
+ function OpenCaseIcon({
1454
+ size = 18,
1455
+ color = "currentColor"
1456
+ }) {
1457
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1458
+ "svg",
1459
+ {
1460
+ xmlns: "http://www.w3.org/2000/svg",
1461
+ width: size,
1462
+ height: size,
1463
+ viewBox: "0 0 24 24",
1464
+ fill: "none",
1465
+ stroke: color,
1466
+ strokeWidth: "2",
1467
+ strokeLinecap: "round",
1468
+ strokeLinejoin: "round",
1469
+ "aria-hidden": "true",
1470
+ children: [
1471
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M14 4h6v6" }),
1472
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M20 4L11 13" }),
1473
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5" })
1474
+ ]
1475
+ }
1476
+ );
1477
+ }
1437
1478
  function PeopleIcon({ size = 18, color = "currentColor" }) {
1438
1479
  return /* @__PURE__ */ jsxRuntime.jsxs(
1439
1480
  "svg",
@@ -1471,15 +1512,19 @@ function BackIcon({ size = 22, color = "currentColor" }) {
1471
1512
  }
1472
1513
  );
1473
1514
  }
1515
+ function stripOrderIdSuffix(name) {
1516
+ return name.replace(/\s*-\s*\d{1,6}\s*$/, "").trim();
1517
+ }
1474
1518
  function resolveDisplayName(patientData, displayName) {
1475
1519
  const localComplete = !!patientData.labName && !!patientData.displayOrderId;
1476
- if (localComplete) return buildChannelName(patientData);
1477
- return cleanChannelName(displayName) || buildChannelName(patientData);
1520
+ const raw = localComplete ? buildChannelName(patientData) : cleanChannelName(displayName) || buildChannelName(patientData);
1521
+ return stripOrderIdSuffix(raw);
1478
1522
  }
1479
1523
  function PatientHeader({
1480
1524
  patientData,
1481
1525
  participants,
1482
1526
  onOpenDicom,
1527
+ onOpenCase,
1483
1528
  onOpenSettings,
1484
1529
  onBack,
1485
1530
  hideName = false,
@@ -1487,29 +1532,58 @@ function PatientHeader({
1487
1532
  className
1488
1533
  }) {
1489
1534
  const hasDicom = !!(patientData.studyId && patientData.storageId);
1490
- const meta = [];
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 });
1504
- }
1535
+ const resolvedName = resolveDisplayName(patientData, displayName);
1536
+ const metaParts = [];
1537
+ if (patientData.patientAge) metaParts.push(String(patientData.patientAge));
1538
+ if (patientData.patientSex) metaParts.push(String(patientData.patientSex));
1539
+ if (patientData.studyType) metaParts.push(patientData.studyType);
1505
1540
  if (patientData.bodyParts && patientData.bodyParts.length > 0) {
1506
- meta.push({
1507
- key: "body",
1508
- label: "Body part",
1509
- value: patientData.bodyParts.join(", ")
1510
- });
1541
+ metaParts.push(patientData.bodyParts.join(", "));
1511
1542
  }
1512
- const hasActions = hasDicom && onOpenDicom || onOpenSettings;
1543
+ const hasActions = hasDicom && onOpenDicom || onOpenCase || onOpenSettings;
1544
+ const actions = hasActions ? /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.actions, children: [
1545
+ hasDicom && onOpenDicom && /* @__PURE__ */ jsxRuntime.jsxs(
1546
+ "button",
1547
+ {
1548
+ onClick: onOpenDicom,
1549
+ style: styles.dicomButton,
1550
+ type: "button",
1551
+ "aria-label": "View DICOM study",
1552
+ children: [
1553
+ /* @__PURE__ */ jsxRuntime.jsx(DicomIcon, { size: 18, color: COLOR.primary }),
1554
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: "View DICOM" })
1555
+ ]
1556
+ }
1557
+ ),
1558
+ onOpenCase && /* @__PURE__ */ jsxRuntime.jsxs(
1559
+ "button",
1560
+ {
1561
+ onClick: onOpenCase,
1562
+ style: styles.openCaseButton,
1563
+ type: "button",
1564
+ "aria-label": "Open case",
1565
+ title: "Open case",
1566
+ children: [
1567
+ /* @__PURE__ */ jsxRuntime.jsx(OpenCaseIcon, { size: 18, color: COLOR.primary }),
1568
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: "Open" })
1569
+ ]
1570
+ }
1571
+ ),
1572
+ onOpenSettings && /* @__PURE__ */ jsxRuntime.jsxs(
1573
+ "button",
1574
+ {
1575
+ onClick: onOpenSettings,
1576
+ style: styles.settingsIconButton,
1577
+ type: "button",
1578
+ "aria-label": `Open channel settings (${participants.length} participants)`,
1579
+ title: "Channel participants",
1580
+ children: [
1581
+ /* @__PURE__ */ jsxRuntime.jsx(PeopleIcon, { size: 18, color: COLOR.neutral600 }),
1582
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.participantCount, children: participants.length })
1583
+ ]
1584
+ }
1585
+ )
1586
+ ] }) : null;
1513
1587
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, style: styles.container, children: [
1514
1588
  !hideName && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.nameRow, children: [
1515
1589
  onBack && /* @__PURE__ */ jsxRuntime.jsx(
@@ -1520,46 +1594,14 @@ function PatientHeader({
1520
1594
  style: styles.backButton,
1521
1595
  "aria-label": "Back to conversations",
1522
1596
  title: "Back to conversations",
1523
- children: /* @__PURE__ */ jsxRuntime.jsx(BackIcon, { size: 22, color: COLOR.neutral800 })
1597
+ children: /* @__PURE__ */ jsxRuntime.jsx(BackIcon, { size: 20, color: COLOR.neutral800 })
1524
1598
  }
1525
1599
  ),
1526
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.nameText, children: resolveDisplayName(patientData, displayName) })
1600
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.nameText, children: resolvedName }),
1601
+ actions
1527
1602
  ] }),
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
- ] })
1603
+ !hideName && metaParts.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles.metaRow, children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.metaText, children: metaParts.join(" \xB7 ") }) }),
1604
+ hideName && actions && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles.actionsOnlyRow, children: actions })
1563
1605
  ] });
1564
1606
  }
1565
1607
  var styles = {
@@ -1567,16 +1609,16 @@ var styles = {
1567
1609
  display: "flex",
1568
1610
  flexDirection: "column",
1569
1611
  backgroundColor: COLOR.primaryBg,
1570
- borderBottom: `1px solid ${COLOR.neutral200}`
1612
+ borderBottom: `1px solid ${COLOR.neutral200}`,
1613
+ padding: `${SPACE.S2} ${SPACE.S4}`,
1614
+ gap: "2px"
1571
1615
  },
1572
- // Optional name row, only when `hideName` is false (compact-inbox path)
1616
+ // Title row back · name · actions in one horizontal line.
1573
1617
  nameRow: {
1574
1618
  display: "flex",
1575
1619
  alignItems: "center",
1576
1620
  gap: SPACE.S2,
1577
- padding: `${SPACE.S3} ${SPACE.S4} ${SPACE.S2}`,
1578
- borderBottom: `1px solid ${COLOR.neutral200}`,
1579
- backgroundColor: COLOR.white
1621
+ minWidth: 0
1580
1622
  },
1581
1623
  backButton: {
1582
1624
  width: SIZE.controlIcon,
@@ -1592,57 +1634,45 @@ var styles = {
1592
1634
  flexShrink: 0
1593
1635
  },
1594
1636
  nameText: {
1637
+ flex: 1,
1638
+ minWidth: 0,
1595
1639
  fontSize: FONT_SIZE.lg,
1596
1640
  fontWeight: FONT_WEIGHT.bold,
1597
1641
  color: COLOR.neutral900,
1598
1642
  lineHeight: LINE_HEIGHT.tight,
1599
1643
  letterSpacing: "-0.01em",
1600
- minWidth: 0,
1601
1644
  overflow: "hidden",
1602
1645
  textOverflow: "ellipsis",
1603
1646
  whiteSpace: "nowrap"
1604
1647
  },
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.
1648
+ // Sub-row beneath the title small muted meta. Aligned under the
1649
+ // name (past the back button) so it visually anchors to the title
1650
+ // rather than the panel edge.
1617
1651
  metaRow: {
1618
1652
  display: "flex",
1619
- flexWrap: "wrap",
1620
- columnGap: SPACE.S5,
1621
- rowGap: SPACE.S2,
1653
+ alignItems: "center",
1654
+ paddingLeft: `calc(${SIZE.controlIcon} + ${SPACE.S2} - ${SPACE.S2})`,
1622
1655
  minWidth: 0
1623
1656
  },
1624
- metaCol: {
1657
+ // When hideName=true (popup mode), only the actions render. The
1658
+ // popup's own title bar carries the name + meta above this row, so
1659
+ // the actions land flush-left here — pinning them to the right
1660
+ // edge would leave the whole left half of the strip awkwardly
1661
+ // empty (the meta that used to fill it now lives in the title bar).
1662
+ actionsOnlyRow: {
1625
1663
  display: "flex",
1626
- flexDirection: "column",
1627
- gap: "2px",
1664
+ justifyContent: "flex-start",
1628
1665
  minWidth: 0
1629
1666
  },
1630
- metaLabel: {
1631
- fontSize: "11px",
1632
- fontWeight: FONT_WEIGHT.bold,
1633
- letterSpacing: "0.06em",
1634
- textTransform: "uppercase",
1635
- color: COLOR.neutral500,
1636
- whiteSpace: "nowrap"
1637
- },
1638
- metaValue: {
1667
+ metaText: {
1639
1668
  fontSize: FONT_SIZE.sm,
1640
- fontWeight: FONT_WEIGHT.semibold,
1641
- color: COLOR.neutral900,
1642
- whiteSpace: "nowrap",
1669
+ fontWeight: FONT_WEIGHT.medium,
1670
+ color: COLOR.neutral600,
1671
+ lineHeight: LINE_HEIGHT.tight,
1643
1672
  overflow: "hidden",
1644
1673
  textOverflow: "ellipsis",
1645
- maxWidth: "180px"
1674
+ whiteSpace: "nowrap",
1675
+ minWidth: 0
1646
1676
  },
1647
1677
  actions: {
1648
1678
  display: "flex",
@@ -1654,7 +1684,7 @@ var styles = {
1654
1684
  alignItems: "center",
1655
1685
  gap: SPACE.S2,
1656
1686
  minHeight: SIZE.control,
1657
- padding: `${SPACE.S2} 14px`,
1687
+ padding: `${SPACE.S1} ${SPACE.S3}`,
1658
1688
  fontSize: FONT_SIZE.sm,
1659
1689
  fontWeight: FONT_WEIGHT.semibold,
1660
1690
  color: COLOR.primary,
@@ -1663,10 +1693,25 @@ var styles = {
1663
1693
  borderRadius: RADIUS.lg,
1664
1694
  cursor: "pointer"
1665
1695
  },
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).
1696
+ // Sibling of the DICOM button same shape so the two read as a pair.
1697
+ // Click hands off to the host's `onOpenCase` callback (role-aware
1698
+ // routing lives on the host side).
1699
+ openCaseButton: {
1700
+ display: "inline-flex",
1701
+ alignItems: "center",
1702
+ gap: SPACE.S2,
1703
+ minHeight: SIZE.control,
1704
+ padding: `${SPACE.S1} ${SPACE.S3}`,
1705
+ fontSize: FONT_SIZE.sm,
1706
+ fontWeight: FONT_WEIGHT.semibold,
1707
+ color: COLOR.primary,
1708
+ backgroundColor: COLOR.white,
1709
+ border: `1.5px solid ${COLOR.primary}`,
1710
+ borderRadius: RADIUS.lg,
1711
+ cursor: "pointer"
1712
+ },
1713
+ // Channel-info button: people icon + participant count. Opens the
1714
+ // channel settings overlay.
1670
1715
  settingsIconButton: {
1671
1716
  minHeight: SIZE.control,
1672
1717
  display: "inline-flex",
@@ -2146,6 +2191,62 @@ function AlertIcon({ size = 14, color = "currentColor" }) {
2146
2191
  }
2147
2192
  );
2148
2193
  }
2194
+ function RoleAvatarIcon({
2195
+ role,
2196
+ size = 18,
2197
+ color = "currentColor",
2198
+ className
2199
+ }) {
2200
+ const common = {
2201
+ width: size,
2202
+ height: size,
2203
+ viewBox: "0 0 24 24",
2204
+ fill: "none",
2205
+ stroke: color,
2206
+ strokeWidth: 1.9,
2207
+ strokeLinecap: "round",
2208
+ strokeLinejoin: "round",
2209
+ "aria-hidden": true,
2210
+ className
2211
+ };
2212
+ switch (role) {
2213
+ case "radiologist":
2214
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { ...common, children: [
2215
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M7.5 8.5h9" }),
2216
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "9.5", r: "4" }),
2217
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "7.5", r: "1", fill: color, stroke: "none" }),
2218
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 21v-1.5a5 5 0 0 1 5-5h6a5 5 0 0 1 5 5V21" })
2219
+ ] });
2220
+ case "lab":
2221
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { ...common, children: [
2222
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 21V9l9-6 9 6v12" }),
2223
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 11v6" }),
2224
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M9 14h6" }),
2225
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M11 21v-3h2v3" }),
2226
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 21h18" })
2227
+ ] });
2228
+ case "physician":
2229
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { ...common, children: [
2230
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M7 3v5a4 4 0 0 0 8 0V3" }),
2231
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M5 3h2" }),
2232
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M15 3h2" }),
2233
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M11 12v3a5 5 0 0 0 5 5h0a5 5 0 0 0 5-5v-2" }),
2234
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "21", cy: "11", r: "2" })
2235
+ ] });
2236
+ case "admin":
2237
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { ...common, children: [
2238
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "12", r: "3" }),
2239
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 2v3" }),
2240
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 19v3" }),
2241
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M5 12H2" }),
2242
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M22 12h-3" }),
2243
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M5.6 5.6l2 2" }),
2244
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M16.4 16.4l2 2" }),
2245
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M5.6 18.4l2-2" }),
2246
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M16.4 7.6l2-2" })
2247
+ ] });
2248
+ }
2249
+ }
2149
2250
  function Spinner({ size = 16, color = COLOR.neutral500, thickness = 2 }) {
2150
2251
  return /* @__PURE__ */ jsxRuntime.jsx(
2151
2252
  "span",
@@ -2234,12 +2335,11 @@ function MessageBubble({
2234
2335
  ...styles5.authorAvatar,
2235
2336
  backgroundColor: roleColor(message.senderRole)
2236
2337
  },
2237
- "aria-hidden": "true",
2238
- children: computeInitials(message.senderName)
2338
+ "aria-label": ROLE_LABELS[message.senderRole] ?? message.senderRole,
2339
+ children: /* @__PURE__ */ jsxRuntime.jsx(RoleAvatarIcon, { role: message.senderRole, size: 20, color: COLOR.white })
2239
2340
  }
2240
2341
  ),
2241
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles5.authorName, children: message.senderName }),
2242
- /* @__PURE__ */ jsxRuntime.jsx(RoleBadge, { role: message.senderRole })
2342
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles5.authorName, children: message.senderName })
2243
2343
  ] }),
2244
2344
  /* @__PURE__ */ jsxRuntime.jsxs(
2245
2345
  "div",
@@ -2314,9 +2414,6 @@ function MessageBubble({
2314
2414
  }
2315
2415
  );
2316
2416
  }
2317
- function computeInitials(name) {
2318
- return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]?.toUpperCase() ?? "").join("") || "?";
2319
- }
2320
2417
  function roleColor(role) {
2321
2418
  switch (role) {
2322
2419
  case "radiologist":
@@ -2401,9 +2498,6 @@ function SystemBubble({ message }) {
2401
2498
  /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles5.systemTime, children: formatTime(message.insertedAt) })
2402
2499
  ] }) });
2403
2500
  }
2404
- function RoleBadge({ role }) {
2405
- return /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles5.roleBadge, backgroundColor: roleColor(role) }, children: ROLE_LABELS[role] ?? role });
2406
- }
2407
2501
  function formatTime(iso) {
2408
2502
  try {
2409
2503
  return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
@@ -2505,16 +2599,6 @@ var styles5 = {
2505
2599
  color: "#fff4d6",
2506
2600
  backgroundColor: "rgba(255, 255, 255, 0.2)"
2507
2601
  },
2508
- roleBadge: {
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
2517
- },
2518
2602
  textBody: {
2519
2603
  margin: 0,
2520
2604
  fontSize: FONT_SIZE.md,
@@ -2978,9 +3062,6 @@ var styles6 = {
2978
3062
  alignItems: "center",
2979
3063
  gap: SPACE.S3,
2980
3064
  margin: `${SPACE.S4} 0 ${SPACE.S3}`,
2981
- position: "sticky",
2982
- top: SPACE.S1,
2983
- zIndex: Z_INDEX.sticky,
2984
3065
  pointerEvents: "none"
2985
3066
  },
2986
3067
  dayHr: {
@@ -2995,11 +3076,7 @@ var styles6 = {
2995
3076
  color: COLOR.neutral500,
2996
3077
  textTransform: "uppercase",
2997
3078
  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
3079
  padding: `2px ${SPACE.S2}`,
3002
- backgroundColor: COLOR.white,
3003
3080
  borderRadius: RADIUS.sm
3004
3081
  },
3005
3082
  bubbleSlot: {
@@ -3297,7 +3374,7 @@ function MessageInput({
3297
3374
  style: styles8.textarea
3298
3375
  }
3299
3376
  ),
3300
- /* @__PURE__ */ jsxRuntime.jsxs(
3377
+ /* @__PURE__ */ jsxRuntime.jsx(
3301
3378
  "button",
3302
3379
  {
3303
3380
  onClick: handleSendText,
@@ -3309,10 +3386,7 @@ function MessageInput({
3309
3386
  opacity: text.trim() ? 1 : 0.5
3310
3387
  },
3311
3388
  type: "button",
3312
- children: [
3313
- /* @__PURE__ */ jsxRuntime.jsx(SendIcon, { size: 20, color: COLOR.white }),
3314
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles8.sendLabel, children: "Send" })
3315
- ]
3389
+ children: /* @__PURE__ */ jsxRuntime.jsx(SendIcon, { size: 20, color: COLOR.white })
3316
3390
  }
3317
3391
  )
3318
3392
  ] })
@@ -3410,29 +3484,33 @@ var styles8 = {
3410
3484
  fontFamily: "inherit",
3411
3485
  transition: "background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease"
3412
3486
  },
3413
- // Pill-shaped send: icon + "Send" label. Always labelled (never icon-
3414
- // only) so the affordance is obvious to first-time users.
3487
+ // Square icon-only send button. The arrow inside an obvious input
3488
+ // context is affordance enough; the word "Send" used to sit next to
3489
+ // it but added clutter without disambiguating anything.
3415
3490
  sendButton: {
3416
3491
  display: "inline-flex",
3417
3492
  alignItems: "center",
3418
- gap: "6px",
3419
- minWidth: "48px",
3493
+ justifyContent: "center",
3494
+ width: "48px",
3420
3495
  height: "48px",
3421
- padding: `0 ${SPACE.S4}`,
3422
3496
  backgroundColor: COLOR.primary,
3423
3497
  color: COLOR.white,
3424
3498
  border: "none",
3425
3499
  borderRadius: "14px",
3426
3500
  cursor: "pointer",
3427
3501
  flexShrink: 0,
3428
- fontFamily: "inherit",
3429
- fontSize: "15px",
3430
- fontWeight: FONT_WEIGHT.semibold,
3431
3502
  transition: "opacity 120ms ease, background-color 120ms ease"
3432
- },
3433
- sendLabel: {
3434
- lineHeight: 1
3435
3503
  }};
3504
+ var ROLE_AVATAR_BG = {
3505
+ radiologist: "#4f46e5",
3506
+ // indigo
3507
+ lab: "#2563eb",
3508
+ // brand blue
3509
+ physician: "#0284c7",
3510
+ // sky
3511
+ admin: "#64748b"
3512
+ // slate
3513
+ };
3436
3514
  function ParticipantsList({
3437
3515
  participants,
3438
3516
  currentUserId,
@@ -3442,7 +3520,17 @@ function ParticipantsList({
3442
3520
  }) {
3443
3521
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className, style: styles9.list, children: participants.map((participant) => /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles9.item, children: [
3444
3522
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles9.avatar, children: [
3445
- participant.avatar ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: participant.avatar, alt: "", style: styles9.avatarImg }) : /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles9.avatarFallback, children: (participant.userName ?? "?").charAt(0).toUpperCase() }),
3523
+ participant.avatar ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: participant.avatar, alt: "", style: styles9.avatarImg }) : /* @__PURE__ */ jsxRuntime.jsx(
3524
+ "span",
3525
+ {
3526
+ style: {
3527
+ ...styles9.avatarFallback,
3528
+ backgroundColor: ROLE_AVATAR_BG[participant.userRole]
3529
+ },
3530
+ "aria-hidden": "true",
3531
+ children: /* @__PURE__ */ jsxRuntime.jsx(RoleAvatarIcon, { role: participant.userRole, size: 18, color: COLOR.white })
3532
+ }
3533
+ ),
3446
3534
  /* @__PURE__ */ jsxRuntime.jsx(
3447
3535
  "span",
3448
3536
  {
@@ -3458,7 +3546,7 @@ function ParticipantsList({
3458
3546
  participant.userName ?? fallbackName(participant.userRole),
3459
3547
  participant.userId === currentUserId && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles9.youBadge, children: " (you)" })
3460
3548
  ] }),
3461
- /* @__PURE__ */ jsxRuntime.jsx(RoleBadge2, { role: participant.userRole })
3549
+ /* @__PURE__ */ jsxRuntime.jsx(RoleBadge, { role: participant.userRole })
3462
3550
  ] }),
3463
3551
  isAdmin && participant.userId !== currentUserId && /* @__PURE__ */ jsxRuntime.jsx(
3464
3552
  "button",
@@ -3486,7 +3574,7 @@ function fallbackName(role) {
3486
3574
  return "User";
3487
3575
  }
3488
3576
  }
3489
- function RoleBadge2({ role }) {
3577
+ function RoleBadge({ role }) {
3490
3578
  const colors = {
3491
3579
  radiologist: { bg: "#eef2ff", text: "#4f46e5" },
3492
3580
  // indigo
@@ -3528,13 +3616,10 @@ var styles9 = {
3528
3616
  width: "32px",
3529
3617
  height: "32px",
3530
3618
  borderRadius: RADIUS.full,
3531
- backgroundColor: COLOR.neutral200,
3532
3619
  display: "flex",
3533
3620
  alignItems: "center",
3534
3621
  justifyContent: "center",
3535
- fontSize: FONT_SIZE.sm,
3536
- fontWeight: FONT_WEIGHT.semibold,
3537
- color: COLOR.neutral500
3622
+ color: COLOR.white
3538
3623
  },
3539
3624
  statusDot: {
3540
3625
  position: "absolute",
@@ -4126,6 +4211,7 @@ function CollabPanel({
4126
4211
  showSeenBy = true,
4127
4212
  onBack,
4128
4213
  hidePatientName = false,
4214
+ hideOpenCase = false,
4129
4215
  onConversationChange,
4130
4216
  themeMode = "light",
4131
4217
  className,
@@ -4168,6 +4254,7 @@ function CollabPanel({
4168
4254
  config.onOpenDicom(patientData.studyId, patientData.storageId);
4169
4255
  }
4170
4256
  };
4257
+ const handleOpenCase = !hideOpenCase && config.onOpenCase ? () => config.onOpenCase?.(patientData.orderId, patientData.displayOrderId) : void 0;
4171
4258
  const handleJumpToMessage = (messageId) => {
4172
4259
  messageListRef.current?.scrollToMessage(messageId);
4173
4260
  };
@@ -4243,6 +4330,7 @@ function CollabPanel({
4243
4330
  patientData,
4244
4331
  participants,
4245
4332
  onOpenDicom: handleOpenDicom,
4333
+ onOpenCase: handleOpenCase,
4246
4334
  onOpenSettings: () => setShowSettings(true),
4247
4335
  onBack,
4248
4336
  hideName: hidePatientName,
@@ -4497,14 +4585,16 @@ function CollabPopup({
4497
4585
  ),
4498
4586
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles13.titleCenter, children: [
4499
4587
  /* @__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
- ] })
4588
+ (() => {
4589
+ const parts = [];
4590
+ if (patientData.patientAge) parts.push(String(patientData.patientAge));
4591
+ if (patientData.patientSex) parts.push(String(patientData.patientSex));
4592
+ if (patientData.studyType) parts.push(patientData.studyType);
4593
+ if (patientData.bodyParts && patientData.bodyParts.length > 0) {
4594
+ parts.push(patientData.bodyParts.join(", "));
4595
+ }
4596
+ return parts.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles13.subRow, children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles13.metaText, children: parts.join(" \xB7 ") }) }) : null;
4597
+ })()
4508
4598
  ] }),
4509
4599
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles13.titleActions, children: [
4510
4600
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -4593,19 +4683,17 @@ var styles13 = {
4593
4683
  subRow: {
4594
4684
  display: "flex",
4595
4685
  alignItems: "center",
4596
- gap: SPACE.S2,
4597
4686
  fontSize: FONT_SIZE.sm,
4598
4687
  color: COLOR.neutral500,
4599
4688
  overflow: "hidden",
4600
4689
  textOverflow: "ellipsis",
4601
4690
  whiteSpace: "nowrap"
4602
4691
  },
4603
- subDot: {
4604
- color: COLOR.neutral400
4605
- },
4606
- mono: {
4607
- fontFamily: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
4608
- fontVariantNumeric: "tabular-nums"
4692
+ metaText: {
4693
+ overflow: "hidden",
4694
+ textOverflow: "ellipsis",
4695
+ whiteSpace: "nowrap",
4696
+ minWidth: 0
4609
4697
  },
4610
4698
  titleActions: {
4611
4699
  display: "flex",
@@ -5418,7 +5506,7 @@ function ConversationListItem({
5418
5506
  const studyType = item.patientSnapshot?.studyType;
5419
5507
  const patientId = item.patientSnapshot?.patientId;
5420
5508
  const showStudyRow = !!(studyType || patientId);
5421
- const initials = computeInitials2(item.patientSnapshot?.patientName || displayName);
5509
+ const initials = computeInitials(item.patientSnapshot?.patientName || displayName);
5422
5510
  return /* @__PURE__ */ jsxRuntime.jsxs(
5423
5511
  "button",
5424
5512
  {
@@ -5513,7 +5601,7 @@ function previewText(message) {
5513
5601
  }
5514
5602
  }
5515
5603
  }
5516
- function computeInitials2(name) {
5604
+ function computeInitials(name) {
5517
5605
  return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() ?? "").join("") || "?";
5518
5606
  }
5519
5607
  var styles15 = {
@@ -6327,7 +6415,7 @@ function useUnreadCount() {
6327
6415
  const [counts, setCounts] = React4.useState({});
6328
6416
  React4.useEffect(() => {
6329
6417
  socket.onUnreadCountUpdate((serverCounts) => {
6330
- setCounts(serverCounts);
6418
+ setCounts(serverCounts.conversation);
6331
6419
  });
6332
6420
  }, [socket]);
6333
6421
  const getCountForConversation = React4.useCallback(