@meetreeve/ui 0.10.0 → 0.12.0

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.d.mts CHANGED
@@ -555,6 +555,17 @@ interface ContextSwitcherItem {
555
555
  accentColor?: string | null;
556
556
  orgId?: string | null;
557
557
  orgName?: string | null;
558
+ /**
559
+ * Pre-resolved noun for what this context IS ("Company", "Space", …),
560
+ * rendered as a muted marker on the row. Optional, and omitted for a
561
+ * host's default kind — labelling every row is noise.
562
+ *
563
+ * Resolved by the host (DEV-8585), matching the `favicon` precedent
564
+ * above: two contexts can share a display name (a brand and a spawned
565
+ * company in the same org both render as "MindFortress"), and only the
566
+ * host knows its own vocabulary for telling them apart.
567
+ */
568
+ kindLabel?: string | null;
558
569
  }
559
570
  interface ContextOrgGroup {
560
571
  orgId: string | null;
@@ -708,6 +719,19 @@ interface ConnectorCredentialTestResult {
708
719
  */
709
720
  permissions_note?: string | null;
710
721
  }
722
+ /**
723
+ * Connector health as the DEV-1964 probe classifies it — Reeve.Connects-wide
724
+ * vocabulary, not provider-specific (the same shape the adbuyer surfaces
725
+ * already read off `GET /api/connects/v1/connections`). Deliberately
726
+ * tri-state, never a `needs_reconnect` boolean (Matt's call, DEV-7873): a
727
+ * probe that has not run yet ("unknown") or has not run at all (the whole
728
+ * field absent) is not the same claim as a probe that ran and failed.
729
+ */
730
+ interface ConnectorConnectionHealth {
731
+ status: "healthy" | "unhealthy" | "unknown";
732
+ error_code?: string | null;
733
+ checked_at?: string | null;
734
+ }
711
735
  interface ConnectableEntry {
712
736
  slug: string;
713
737
  name: string;
@@ -738,6 +762,21 @@ interface ConnectableEntry {
738
762
  } | null;
739
763
  /** true = the viewer owns this connection (enables manage-sharing). */
740
764
  owned_by_viewer?: boolean;
765
+ /**
766
+ * Live health probe for this connection (DEV-1964 / DEV-7873), when the
767
+ * backend has one. Absent/undefined is NOT "healthy" — it means no probe
768
+ * has run; the picker only ever reacts to an explicit "unhealthy"
769
+ * status, so "unknown" and absent both render as a plain connected
770
+ * tile, same as before this field existed.
771
+ */
772
+ health?: ConnectorConnectionHealth | null;
773
+ /**
774
+ * Human-readable identity of the external account this connection
775
+ * authenticated as (an email, a workspace name, whatever the provider's
776
+ * own identity claim resolves to). Optional and provider-agnostic — the
777
+ * picker never assumes an email shape or synthesizes this itself.
778
+ */
779
+ account_label?: string | null;
741
780
  };
742
781
  /** true = tenant has a live connection but the entry left the allowlist. */
743
782
  deprecated: boolean;
@@ -755,6 +794,15 @@ interface ConnectableEntry {
755
794
  * connect button renders, exactly as before.
756
795
  */
757
796
  credential_fields?: ConnectorCredentialField[] | null;
797
+ /**
798
+ * Plain-language description of what a LIVE connection actually reads or
799
+ * writes (e.g. "Reads recent activity to build your weekly summary") —
800
+ * entirely server-authored copy, same posture as `category`/`name`.
801
+ * Rendered only once connected (it describes a live grant, not the
802
+ * catalog listing); absent renders nothing. This file must never
803
+ * synthesize this text itself for a specific provider.
804
+ */
805
+ access_summary?: string | null;
758
806
  }
759
807
  interface ConnectorPickerClient {
760
808
  listConnectable(opts: {
@@ -818,4 +866,4 @@ interface ConnectorPickerProps {
818
866
  }
819
867
  declare function ConnectorPicker({ client, purpose, lens, layout, onConnected, }: ConnectorPickerProps): react.JSX.Element | null;
820
868
 
821
- export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type ConnectableCapability, type ConnectableEntry, type ConnectableTransport, type ConnectionKind, type ConnectorCredentialField, type ConnectorCredentialTestResult, type ConnectorGrant, type ConnectorGrantInput, type ConnectorGranteeType, type ConnectorGuideStep, ConnectorPicker, type ConnectorPickerClient, type ConnectorPickerLens, type ConnectorPickerProps, type ConnectorRoleOption, type ConnectorSetupGuide, type ContextOrgGroup, ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, GLYPH_CANVAS_FILL, type GroupRungItem, type HeaderNavItem, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, type OrgConnectPolicy, REEVE_ACCENT, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, WORDMARK_WEIGHT, deriveFallbackLadder, faviconUrl, groupByOrg, reconcileLadder, resolveLadder, useLadderRung, useResponsivePlaceholder, zSidebarLadder };
869
+ export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type ConnectableCapability, type ConnectableEntry, type ConnectableTransport, type ConnectionKind, type ConnectorConnectionHealth, type ConnectorCredentialField, type ConnectorCredentialTestResult, type ConnectorGrant, type ConnectorGrantInput, type ConnectorGranteeType, type ConnectorGuideStep, ConnectorPicker, type ConnectorPickerClient, type ConnectorPickerLens, type ConnectorPickerProps, type ConnectorRoleOption, type ConnectorSetupGuide, type ContextOrgGroup, ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, GLYPH_CANVAS_FILL, type GroupRungItem, type HeaderNavItem, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, type OrgConnectPolicy, REEVE_ACCENT, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, WORDMARK_WEIGHT, deriveFallbackLadder, faviconUrl, groupByOrg, reconcileLadder, resolveLadder, useLadderRung, useResponsivePlaceholder, zSidebarLadder };
package/dist/index.d.ts CHANGED
@@ -555,6 +555,17 @@ interface ContextSwitcherItem {
555
555
  accentColor?: string | null;
556
556
  orgId?: string | null;
557
557
  orgName?: string | null;
558
+ /**
559
+ * Pre-resolved noun for what this context IS ("Company", "Space", …),
560
+ * rendered as a muted marker on the row. Optional, and omitted for a
561
+ * host's default kind — labelling every row is noise.
562
+ *
563
+ * Resolved by the host (DEV-8585), matching the `favicon` precedent
564
+ * above: two contexts can share a display name (a brand and a spawned
565
+ * company in the same org both render as "MindFortress"), and only the
566
+ * host knows its own vocabulary for telling them apart.
567
+ */
568
+ kindLabel?: string | null;
558
569
  }
559
570
  interface ContextOrgGroup {
560
571
  orgId: string | null;
@@ -708,6 +719,19 @@ interface ConnectorCredentialTestResult {
708
719
  */
709
720
  permissions_note?: string | null;
710
721
  }
722
+ /**
723
+ * Connector health as the DEV-1964 probe classifies it — Reeve.Connects-wide
724
+ * vocabulary, not provider-specific (the same shape the adbuyer surfaces
725
+ * already read off `GET /api/connects/v1/connections`). Deliberately
726
+ * tri-state, never a `needs_reconnect` boolean (Matt's call, DEV-7873): a
727
+ * probe that has not run yet ("unknown") or has not run at all (the whole
728
+ * field absent) is not the same claim as a probe that ran and failed.
729
+ */
730
+ interface ConnectorConnectionHealth {
731
+ status: "healthy" | "unhealthy" | "unknown";
732
+ error_code?: string | null;
733
+ checked_at?: string | null;
734
+ }
711
735
  interface ConnectableEntry {
712
736
  slug: string;
713
737
  name: string;
@@ -738,6 +762,21 @@ interface ConnectableEntry {
738
762
  } | null;
739
763
  /** true = the viewer owns this connection (enables manage-sharing). */
740
764
  owned_by_viewer?: boolean;
765
+ /**
766
+ * Live health probe for this connection (DEV-1964 / DEV-7873), when the
767
+ * backend has one. Absent/undefined is NOT "healthy" — it means no probe
768
+ * has run; the picker only ever reacts to an explicit "unhealthy"
769
+ * status, so "unknown" and absent both render as a plain connected
770
+ * tile, same as before this field existed.
771
+ */
772
+ health?: ConnectorConnectionHealth | null;
773
+ /**
774
+ * Human-readable identity of the external account this connection
775
+ * authenticated as (an email, a workspace name, whatever the provider's
776
+ * own identity claim resolves to). Optional and provider-agnostic — the
777
+ * picker never assumes an email shape or synthesizes this itself.
778
+ */
779
+ account_label?: string | null;
741
780
  };
742
781
  /** true = tenant has a live connection but the entry left the allowlist. */
743
782
  deprecated: boolean;
@@ -755,6 +794,15 @@ interface ConnectableEntry {
755
794
  * connect button renders, exactly as before.
756
795
  */
757
796
  credential_fields?: ConnectorCredentialField[] | null;
797
+ /**
798
+ * Plain-language description of what a LIVE connection actually reads or
799
+ * writes (e.g. "Reads recent activity to build your weekly summary") —
800
+ * entirely server-authored copy, same posture as `category`/`name`.
801
+ * Rendered only once connected (it describes a live grant, not the
802
+ * catalog listing); absent renders nothing. This file must never
803
+ * synthesize this text itself for a specific provider.
804
+ */
805
+ access_summary?: string | null;
758
806
  }
759
807
  interface ConnectorPickerClient {
760
808
  listConnectable(opts: {
@@ -818,4 +866,4 @@ interface ConnectorPickerProps {
818
866
  }
819
867
  declare function ConnectorPicker({ client, purpose, lens, layout, onConnected, }: ConnectorPickerProps): react.JSX.Element | null;
820
868
 
821
- export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type ConnectableCapability, type ConnectableEntry, type ConnectableTransport, type ConnectionKind, type ConnectorCredentialField, type ConnectorCredentialTestResult, type ConnectorGrant, type ConnectorGrantInput, type ConnectorGranteeType, type ConnectorGuideStep, ConnectorPicker, type ConnectorPickerClient, type ConnectorPickerLens, type ConnectorPickerProps, type ConnectorRoleOption, type ConnectorSetupGuide, type ContextOrgGroup, ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, GLYPH_CANVAS_FILL, type GroupRungItem, type HeaderNavItem, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, type OrgConnectPolicy, REEVE_ACCENT, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, WORDMARK_WEIGHT, deriveFallbackLadder, faviconUrl, groupByOrg, reconcileLadder, resolveLadder, useLadderRung, useResponsivePlaceholder, zSidebarLadder };
869
+ export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type ConnectableCapability, type ConnectableEntry, type ConnectableTransport, type ConnectionKind, type ConnectorConnectionHealth, type ConnectorCredentialField, type ConnectorCredentialTestResult, type ConnectorGrant, type ConnectorGrantInput, type ConnectorGranteeType, type ConnectorGuideStep, ConnectorPicker, type ConnectorPickerClient, type ConnectorPickerLens, type ConnectorPickerProps, type ConnectorRoleOption, type ConnectorSetupGuide, type ContextOrgGroup, ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, GLYPH_CANVAS_FILL, type GroupRungItem, type HeaderNavItem, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, type OrgConnectPolicy, REEVE_ACCENT, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, WORDMARK_WEIGHT, deriveFallbackLadder, faviconUrl, groupByOrg, reconcileLadder, resolveLadder, useLadderRung, useResponsivePlaceholder, zSidebarLadder };
package/dist/index.js CHANGED
@@ -1403,6 +1403,10 @@ function ContextSwitcher({
1403
1403
  children: [
1404
1404
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(BrandGlyph, { brand: toBrand(item), subtle: true }),
1405
1405
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "flex-1 truncate text-left text-foreground/90", children: item.name }),
1406
+ item.kindLabel && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(import_jsx_runtime6.Fragment, { children: [
1407
+ " ",
1408
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "shrink-0 text-[11px] text-muted-foreground", children: item.kindLabel })
1409
+ ] }),
1406
1410
  isActive && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_lucide_react3.Check, { className: "size-3.5 text-emerald-500" })
1407
1411
  ]
1408
1412
  },
@@ -1421,6 +1425,8 @@ function ContextSwitcher({
1421
1425
  var import_react9 = require("react");
1422
1426
  var import_jsx_runtime7 = require("react/jsx-runtime");
1423
1427
  var DEFAULT_CONNECT_ERROR = "Couldn't start the connection. Please try again.";
1428
+ var MAX_RECONNECT_REFOCUS_CHECKS = 3;
1429
+ var RECONNECT_UNCONFIRMED_ERROR = "Couldn't confirm the reconnect \u2014 the connection still reports unhealthy. Try again, or disconnect and reconnect.";
1424
1430
  var DEFAULT_DISCONNECT_ERROR = "Couldn't disconnect. Please try again.";
1425
1431
  var DEFAULT_LOAD_ERROR = "Couldn't load connections. Please try again.";
1426
1432
  var DEFAULT_GRANTS_LOAD_ERROR = "Couldn't load sharing. Please try again.";
@@ -1429,6 +1435,8 @@ var DEFAULT_REVOKE_ERROR = "Couldn't revoke access. Please try again.";
1429
1435
  var DEFAULT_ORG_DISABLED_REASON = "Org-wide connections aren't available to you.";
1430
1436
  var DEFAULT_CREDENTIAL_ERROR = "Couldn't save that credential. Please try again.";
1431
1437
  var DEFAULT_CREDENTIAL_TEST_ERROR = "Couldn't test that credential. Please try again.";
1438
+ var DEFAULT_RECONNECT_MESSAGE = "This connection needs to be reconnected to keep working.";
1439
+ var DISCONNECT_REVOKES_HINT = "Disconnecting revokes Reeve's access to this account.";
1432
1440
  var UNVERIFIABLE_CREDENTIAL_NOTE = "We can't verify this automatically \u2014 saving it is safe, but nothing has checked it yet.";
1433
1441
  var SKELETON_COUNT = 3;
1434
1442
  var UNGROUNDED_PLACEHOLDER = /\{\{[^}]*\}\}/;
@@ -1462,6 +1470,9 @@ function ownerLabel(entry) {
1462
1470
  function isSharedWithViewer(entry) {
1463
1471
  return entry.connection.status === "connected" && Boolean(entry.connection.shared_by) && !entry.connection.owned_by_viewer;
1464
1472
  }
1473
+ function isUnhealthy(entry) {
1474
+ return entry.connection.status === "connected" && entry.connection.health?.status === "unhealthy";
1475
+ }
1465
1476
  function ConnectorPicker({
1466
1477
  client,
1467
1478
  purpose,
@@ -1473,6 +1484,9 @@ function ConnectorPicker({
1473
1484
  const [entries, setEntries] = (0, import_react9.useState)(null);
1474
1485
  const [loadError, setLoadError] = (0, import_react9.useState)(null);
1475
1486
  const [uiState, setUiState] = (0, import_react9.useState)({});
1487
+ const reconnectingRef = (0, import_react9.useRef)(/* @__PURE__ */ new Set());
1488
+ const reconnectChecksRef = (0, import_react9.useRef)(/* @__PURE__ */ new Map());
1489
+ const refreshInFlightRef = (0, import_react9.useRef)(false);
1476
1490
  const grantsSupported = typeof client.listGrants === "function" && typeof client.grant === "function" && typeof client.revokeGrant === "function";
1477
1491
  const credentialsSupported = typeof client.submitCredentials === "function";
1478
1492
  const credentialTestSupported = typeof client.testCredentials === "function";
@@ -1501,20 +1515,45 @@ function ConnectorPicker({
1501
1515
  void load();
1502
1516
  }, [gated, load, purpose]);
1503
1517
  const refreshAfterReturn = (0, import_react9.useCallback)(async () => {
1504
- const list = await load();
1505
- if (!list) return;
1506
- setUiState((prev) => {
1507
- let changed = false;
1508
- const next = { ...prev };
1518
+ if (refreshInFlightRef.current) return;
1519
+ refreshInFlightRef.current = true;
1520
+ try {
1521
+ const list = await load();
1522
+ if (!list) return;
1523
+ const stillUnhealthyChecks = /* @__PURE__ */ new Map();
1509
1524
  for (const entry of list) {
1510
- if (next[entry.slug]?.kind === "connecting" && entry.connection.status === "connected") {
1511
- changed = true;
1512
- delete next[entry.slug];
1513
- onConnectedRef.current?.(entry);
1514
- }
1525
+ const stillUnhealthy = reconnectingRef.current.has(entry.slug) && entry.connection.health?.status === "unhealthy";
1526
+ if (!stillUnhealthy) continue;
1527
+ const checks = (reconnectChecksRef.current.get(entry.slug) ?? 0) + 1;
1528
+ reconnectChecksRef.current.set(entry.slug, checks);
1529
+ stillUnhealthyChecks.set(entry.slug, checks);
1515
1530
  }
1516
- return changed ? next : prev;
1517
- });
1531
+ setUiState((prev) => {
1532
+ let changed = false;
1533
+ const next = { ...prev };
1534
+ for (const entry of list) {
1535
+ if (next[entry.slug]?.kind !== "connecting" || entry.connection.status !== "connected") {
1536
+ continue;
1537
+ }
1538
+ const checks = stillUnhealthyChecks.get(entry.slug);
1539
+ if (checks !== void 0 && checks >= MAX_RECONNECT_REFOCUS_CHECKS) {
1540
+ changed = true;
1541
+ reconnectingRef.current.delete(entry.slug);
1542
+ reconnectChecksRef.current.delete(entry.slug);
1543
+ next[entry.slug] = { kind: "error", message: RECONNECT_UNCONFIRMED_ERROR };
1544
+ } else if (checks === void 0) {
1545
+ changed = true;
1546
+ delete next[entry.slug];
1547
+ reconnectingRef.current.delete(entry.slug);
1548
+ reconnectChecksRef.current.delete(entry.slug);
1549
+ onConnectedRef.current?.(entry);
1550
+ }
1551
+ }
1552
+ return changed ? next : prev;
1553
+ });
1554
+ } finally {
1555
+ refreshInFlightRef.current = false;
1556
+ }
1518
1557
  }, [load]);
1519
1558
  (0, import_react9.useEffect)(() => {
1520
1559
  if (gated) return;
@@ -1535,6 +1574,12 @@ function ConnectorPicker({
1535
1574
  }, [gated, refreshAfterReturn]);
1536
1575
  const handleConnect = (0, import_react9.useCallback)(
1537
1576
  async (entry, kind) => {
1577
+ if (isUnhealthy(entry)) {
1578
+ reconnectingRef.current.add(entry.slug);
1579
+ } else {
1580
+ reconnectingRef.current.delete(entry.slug);
1581
+ }
1582
+ reconnectChecksRef.current.delete(entry.slug);
1538
1583
  setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "connecting" } }));
1539
1584
  try {
1540
1585
  const result = await clientRef.current.connect(entry, { kind });
@@ -1544,6 +1589,8 @@ function ConnectorPicker({
1544
1589
  }
1545
1590
  const list = await load();
1546
1591
  const updated = list?.find((e) => e.slug === entry.slug) ?? null;
1592
+ reconnectingRef.current.delete(entry.slug);
1593
+ reconnectChecksRef.current.delete(entry.slug);
1547
1594
  setUiState((prev) => {
1548
1595
  const next = { ...prev };
1549
1596
  delete next[entry.slug];
@@ -1553,6 +1600,8 @@ function ConnectorPicker({
1553
1600
  onConnectedRef.current?.(updated);
1554
1601
  }
1555
1602
  } catch (err) {
1603
+ reconnectingRef.current.delete(entry.slug);
1604
+ reconnectChecksRef.current.delete(entry.slug);
1556
1605
  const message = errorMessageFrom(err, DEFAULT_CONNECT_ERROR);
1557
1606
  setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "error", message } }));
1558
1607
  }
@@ -1721,6 +1770,7 @@ function ConnectorTile({
1721
1770
  const isConnecting = ui?.kind === "connecting";
1722
1771
  const isDisconnecting = ui?.kind === "disconnecting";
1723
1772
  const errorMessage = ui?.kind === "error" ? ui.message : null;
1773
+ const unhealthy = isUnhealthy(entry);
1724
1774
  const [kind, setKind] = (0, import_react9.useState)("personal");
1725
1775
  const [sharingOpen, setSharingOpen] = (0, import_react9.useState)(false);
1726
1776
  const [setupOpen, setSetupOpen] = (0, import_react9.useState)(false);
@@ -1770,6 +1820,34 @@ function ConnectorTile({
1770
1820
  children: entry.connection.since ? `Connected since ${formatSince(entry.connection.since)}` : "Connected"
1771
1821
  }
1772
1822
  ),
1823
+ isConnected && !shared && entry.connection.account_label && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
1824
+ "p",
1825
+ {
1826
+ "data-testid": `connector-account-${entry.slug}`,
1827
+ className: "text-xs text-muted-foreground",
1828
+ children: [
1829
+ "Connected as ",
1830
+ entry.connection.account_label
1831
+ ]
1832
+ }
1833
+ ),
1834
+ isConnected && entry.access_summary && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1835
+ "p",
1836
+ {
1837
+ "data-testid": `connector-access-summary-${entry.slug}`,
1838
+ className: "text-xs text-muted-foreground",
1839
+ children: entry.access_summary
1840
+ }
1841
+ ),
1842
+ unhealthy && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1843
+ "p",
1844
+ {
1845
+ role: "alert",
1846
+ "data-testid": `connector-unhealthy-${entry.slug}`,
1847
+ className: "text-xs font-medium text-destructive",
1848
+ children: DEFAULT_RECONNECT_MESSAGE
1849
+ }
1850
+ ),
1773
1851
  attribution && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1774
1852
  "p",
1775
1853
  {
@@ -1832,17 +1910,46 @@ function ConnectorTile({
1832
1910
  errorMessage && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { role: "alert", "data-testid": `connector-error-${entry.slug}`, className: "text-xs text-destructive", children: errorMessage })
1833
1911
  ] }),
1834
1912
  isConnected ? /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "flex shrink-0 flex-col items-end gap-1", children: [
1913
+ unhealthy && !shared && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1914
+ "button",
1915
+ {
1916
+ type: "button",
1917
+ "data-testid": `connector-reconnect-${entry.slug}`,
1918
+ onClick: () => onConnect(entry, entry.connection.kind ?? "personal"),
1919
+ disabled: isConnecting || isDisconnecting,
1920
+ className: "shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50",
1921
+ children: isConnecting ? "Reconnecting\u2026" : "Reconnect"
1922
+ }
1923
+ ),
1835
1924
  canDisconnect && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1836
1925
  "button",
1837
1926
  {
1838
1927
  type: "button",
1839
1928
  "data-testid": `connector-disconnect-${entry.slug}`,
1929
+ title: DISCONNECT_REVOKES_HINT,
1840
1930
  onClick: () => onDisconnect(entry),
1841
- disabled: isDisconnecting,
1931
+ disabled: isDisconnecting || isConnecting,
1842
1932
  className: "shrink-0 rounded-md px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50",
1843
1933
  children: isDisconnecting ? "Disconnecting\u2026" : "Disconnect"
1844
1934
  }
1845
1935
  ),
1936
+ canDisconnect && // Outcome-eval finding (DEV-7873): with no width cap, this
1937
+ // paragraph's one long sentence forced the WHOLE shrink-0
1938
+ // button column to grow to its natural (unwrapped) width,
1939
+ // squeezing the sibling `min-w-0 flex-1` text column down to
1940
+ // one word per line at any realistic tile width. RTL's jsdom
1941
+ // tests never caught it — jsdom doesn't lay out real flexbox
1942
+ // sizing, so every textContent assertion still passed. A
1943
+ // narrow max-width forces the hint to wrap within the same
1944
+ // footprint as the button above it instead.
1945
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1946
+ "p",
1947
+ {
1948
+ "data-testid": `connector-disconnect-hint-${entry.slug}`,
1949
+ className: "max-w-28 text-right text-[11px] text-muted-foreground",
1950
+ children: DISCONNECT_REVOKES_HINT
1951
+ }
1952
+ ),
1846
1953
  canManageSharing && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1847
1954
  "button",
1848
1955
  {
package/dist/index.mjs CHANGED
@@ -1273,7 +1273,7 @@ function DropdownMenuLabel({
1273
1273
  }
1274
1274
 
1275
1275
  // src/context-switcher.tsx
1276
- import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1276
+ import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1277
1277
  function groupByOrg(items) {
1278
1278
  return groupItemsByOrg(items, {
1279
1279
  orgId: (it) => it.orgId,
@@ -1348,6 +1348,10 @@ function ContextSwitcher({
1348
1348
  children: [
1349
1349
  /* @__PURE__ */ jsx6(BrandGlyph, { brand: toBrand(item), subtle: true }),
1350
1350
  /* @__PURE__ */ jsx6("span", { className: "flex-1 truncate text-left text-foreground/90", children: item.name }),
1351
+ item.kindLabel && /* @__PURE__ */ jsxs5(Fragment3, { children: [
1352
+ " ",
1353
+ /* @__PURE__ */ jsx6("span", { className: "shrink-0 text-[11px] text-muted-foreground", children: item.kindLabel })
1354
+ ] }),
1351
1355
  isActive && /* @__PURE__ */ jsx6(Check, { className: "size-3.5 text-emerald-500" })
1352
1356
  ]
1353
1357
  },
@@ -1364,8 +1368,10 @@ function ContextSwitcher({
1364
1368
 
1365
1369
  // src/connector-picker.tsx
1366
1370
  import { useCallback as useCallback4, useEffect as useEffect6, useRef as useRef5, useState as useState7 } from "react";
1367
- import { Fragment as Fragment3, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1371
+ import { Fragment as Fragment4, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1368
1372
  var DEFAULT_CONNECT_ERROR = "Couldn't start the connection. Please try again.";
1373
+ var MAX_RECONNECT_REFOCUS_CHECKS = 3;
1374
+ var RECONNECT_UNCONFIRMED_ERROR = "Couldn't confirm the reconnect \u2014 the connection still reports unhealthy. Try again, or disconnect and reconnect.";
1369
1375
  var DEFAULT_DISCONNECT_ERROR = "Couldn't disconnect. Please try again.";
1370
1376
  var DEFAULT_LOAD_ERROR = "Couldn't load connections. Please try again.";
1371
1377
  var DEFAULT_GRANTS_LOAD_ERROR = "Couldn't load sharing. Please try again.";
@@ -1374,6 +1380,8 @@ var DEFAULT_REVOKE_ERROR = "Couldn't revoke access. Please try again.";
1374
1380
  var DEFAULT_ORG_DISABLED_REASON = "Org-wide connections aren't available to you.";
1375
1381
  var DEFAULT_CREDENTIAL_ERROR = "Couldn't save that credential. Please try again.";
1376
1382
  var DEFAULT_CREDENTIAL_TEST_ERROR = "Couldn't test that credential. Please try again.";
1383
+ var DEFAULT_RECONNECT_MESSAGE = "This connection needs to be reconnected to keep working.";
1384
+ var DISCONNECT_REVOKES_HINT = "Disconnecting revokes Reeve's access to this account.";
1377
1385
  var UNVERIFIABLE_CREDENTIAL_NOTE = "We can't verify this automatically \u2014 saving it is safe, but nothing has checked it yet.";
1378
1386
  var SKELETON_COUNT = 3;
1379
1387
  var UNGROUNDED_PLACEHOLDER = /\{\{[^}]*\}\}/;
@@ -1407,6 +1415,9 @@ function ownerLabel(entry) {
1407
1415
  function isSharedWithViewer(entry) {
1408
1416
  return entry.connection.status === "connected" && Boolean(entry.connection.shared_by) && !entry.connection.owned_by_viewer;
1409
1417
  }
1418
+ function isUnhealthy(entry) {
1419
+ return entry.connection.status === "connected" && entry.connection.health?.status === "unhealthy";
1420
+ }
1410
1421
  function ConnectorPicker({
1411
1422
  client,
1412
1423
  purpose,
@@ -1418,6 +1429,9 @@ function ConnectorPicker({
1418
1429
  const [entries, setEntries] = useState7(null);
1419
1430
  const [loadError, setLoadError] = useState7(null);
1420
1431
  const [uiState, setUiState] = useState7({});
1432
+ const reconnectingRef = useRef5(/* @__PURE__ */ new Set());
1433
+ const reconnectChecksRef = useRef5(/* @__PURE__ */ new Map());
1434
+ const refreshInFlightRef = useRef5(false);
1421
1435
  const grantsSupported = typeof client.listGrants === "function" && typeof client.grant === "function" && typeof client.revokeGrant === "function";
1422
1436
  const credentialsSupported = typeof client.submitCredentials === "function";
1423
1437
  const credentialTestSupported = typeof client.testCredentials === "function";
@@ -1446,20 +1460,45 @@ function ConnectorPicker({
1446
1460
  void load();
1447
1461
  }, [gated, load, purpose]);
1448
1462
  const refreshAfterReturn = useCallback4(async () => {
1449
- const list = await load();
1450
- if (!list) return;
1451
- setUiState((prev) => {
1452
- let changed = false;
1453
- const next = { ...prev };
1463
+ if (refreshInFlightRef.current) return;
1464
+ refreshInFlightRef.current = true;
1465
+ try {
1466
+ const list = await load();
1467
+ if (!list) return;
1468
+ const stillUnhealthyChecks = /* @__PURE__ */ new Map();
1454
1469
  for (const entry of list) {
1455
- if (next[entry.slug]?.kind === "connecting" && entry.connection.status === "connected") {
1456
- changed = true;
1457
- delete next[entry.slug];
1458
- onConnectedRef.current?.(entry);
1459
- }
1470
+ const stillUnhealthy = reconnectingRef.current.has(entry.slug) && entry.connection.health?.status === "unhealthy";
1471
+ if (!stillUnhealthy) continue;
1472
+ const checks = (reconnectChecksRef.current.get(entry.slug) ?? 0) + 1;
1473
+ reconnectChecksRef.current.set(entry.slug, checks);
1474
+ stillUnhealthyChecks.set(entry.slug, checks);
1460
1475
  }
1461
- return changed ? next : prev;
1462
- });
1476
+ setUiState((prev) => {
1477
+ let changed = false;
1478
+ const next = { ...prev };
1479
+ for (const entry of list) {
1480
+ if (next[entry.slug]?.kind !== "connecting" || entry.connection.status !== "connected") {
1481
+ continue;
1482
+ }
1483
+ const checks = stillUnhealthyChecks.get(entry.slug);
1484
+ if (checks !== void 0 && checks >= MAX_RECONNECT_REFOCUS_CHECKS) {
1485
+ changed = true;
1486
+ reconnectingRef.current.delete(entry.slug);
1487
+ reconnectChecksRef.current.delete(entry.slug);
1488
+ next[entry.slug] = { kind: "error", message: RECONNECT_UNCONFIRMED_ERROR };
1489
+ } else if (checks === void 0) {
1490
+ changed = true;
1491
+ delete next[entry.slug];
1492
+ reconnectingRef.current.delete(entry.slug);
1493
+ reconnectChecksRef.current.delete(entry.slug);
1494
+ onConnectedRef.current?.(entry);
1495
+ }
1496
+ }
1497
+ return changed ? next : prev;
1498
+ });
1499
+ } finally {
1500
+ refreshInFlightRef.current = false;
1501
+ }
1463
1502
  }, [load]);
1464
1503
  useEffect6(() => {
1465
1504
  if (gated) return;
@@ -1480,6 +1519,12 @@ function ConnectorPicker({
1480
1519
  }, [gated, refreshAfterReturn]);
1481
1520
  const handleConnect = useCallback4(
1482
1521
  async (entry, kind) => {
1522
+ if (isUnhealthy(entry)) {
1523
+ reconnectingRef.current.add(entry.slug);
1524
+ } else {
1525
+ reconnectingRef.current.delete(entry.slug);
1526
+ }
1527
+ reconnectChecksRef.current.delete(entry.slug);
1483
1528
  setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "connecting" } }));
1484
1529
  try {
1485
1530
  const result = await clientRef.current.connect(entry, { kind });
@@ -1489,6 +1534,8 @@ function ConnectorPicker({
1489
1534
  }
1490
1535
  const list = await load();
1491
1536
  const updated = list?.find((e) => e.slug === entry.slug) ?? null;
1537
+ reconnectingRef.current.delete(entry.slug);
1538
+ reconnectChecksRef.current.delete(entry.slug);
1492
1539
  setUiState((prev) => {
1493
1540
  const next = { ...prev };
1494
1541
  delete next[entry.slug];
@@ -1498,6 +1545,8 @@ function ConnectorPicker({
1498
1545
  onConnectedRef.current?.(updated);
1499
1546
  }
1500
1547
  } catch (err) {
1548
+ reconnectingRef.current.delete(entry.slug);
1549
+ reconnectChecksRef.current.delete(entry.slug);
1501
1550
  const message = errorMessageFrom(err, DEFAULT_CONNECT_ERROR);
1502
1551
  setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "error", message } }));
1503
1552
  }
@@ -1666,6 +1715,7 @@ function ConnectorTile({
1666
1715
  const isConnecting = ui?.kind === "connecting";
1667
1716
  const isDisconnecting = ui?.kind === "disconnecting";
1668
1717
  const errorMessage = ui?.kind === "error" ? ui.message : null;
1718
+ const unhealthy = isUnhealthy(entry);
1669
1719
  const [kind, setKind] = useState7("personal");
1670
1720
  const [sharingOpen, setSharingOpen] = useState7(false);
1671
1721
  const [setupOpen, setSetupOpen] = useState7(false);
@@ -1702,7 +1752,7 @@ function ConnectorTile({
1702
1752
  ] }),
1703
1753
  /* @__PURE__ */ jsxs6("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
1704
1754
  /* @__PURE__ */ jsx7("span", { "data-testid": `connector-capability-${entry.slug}`, children: capabilityLabel(entry.capability) }),
1705
- entry.category && /* @__PURE__ */ jsxs6(Fragment3, { children: [
1755
+ entry.category && /* @__PURE__ */ jsxs6(Fragment4, { children: [
1706
1756
  /* @__PURE__ */ jsx7("span", { "aria-hidden": "true", children: "\xB7" }),
1707
1757
  /* @__PURE__ */ jsx7("span", { className: "truncate", children: entry.category })
1708
1758
  ] })
@@ -1715,6 +1765,34 @@ function ConnectorTile({
1715
1765
  children: entry.connection.since ? `Connected since ${formatSince(entry.connection.since)}` : "Connected"
1716
1766
  }
1717
1767
  ),
1768
+ isConnected && !shared && entry.connection.account_label && /* @__PURE__ */ jsxs6(
1769
+ "p",
1770
+ {
1771
+ "data-testid": `connector-account-${entry.slug}`,
1772
+ className: "text-xs text-muted-foreground",
1773
+ children: [
1774
+ "Connected as ",
1775
+ entry.connection.account_label
1776
+ ]
1777
+ }
1778
+ ),
1779
+ isConnected && entry.access_summary && /* @__PURE__ */ jsx7(
1780
+ "p",
1781
+ {
1782
+ "data-testid": `connector-access-summary-${entry.slug}`,
1783
+ className: "text-xs text-muted-foreground",
1784
+ children: entry.access_summary
1785
+ }
1786
+ ),
1787
+ unhealthy && /* @__PURE__ */ jsx7(
1788
+ "p",
1789
+ {
1790
+ role: "alert",
1791
+ "data-testid": `connector-unhealthy-${entry.slug}`,
1792
+ className: "text-xs font-medium text-destructive",
1793
+ children: DEFAULT_RECONNECT_MESSAGE
1794
+ }
1795
+ ),
1718
1796
  attribution && /* @__PURE__ */ jsx7(
1719
1797
  "p",
1720
1798
  {
@@ -1777,17 +1855,46 @@ function ConnectorTile({
1777
1855
  errorMessage && /* @__PURE__ */ jsx7("p", { role: "alert", "data-testid": `connector-error-${entry.slug}`, className: "text-xs text-destructive", children: errorMessage })
1778
1856
  ] }),
1779
1857
  isConnected ? /* @__PURE__ */ jsxs6("div", { className: "flex shrink-0 flex-col items-end gap-1", children: [
1858
+ unhealthy && !shared && /* @__PURE__ */ jsx7(
1859
+ "button",
1860
+ {
1861
+ type: "button",
1862
+ "data-testid": `connector-reconnect-${entry.slug}`,
1863
+ onClick: () => onConnect(entry, entry.connection.kind ?? "personal"),
1864
+ disabled: isConnecting || isDisconnecting,
1865
+ className: "shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50",
1866
+ children: isConnecting ? "Reconnecting\u2026" : "Reconnect"
1867
+ }
1868
+ ),
1780
1869
  canDisconnect && /* @__PURE__ */ jsx7(
1781
1870
  "button",
1782
1871
  {
1783
1872
  type: "button",
1784
1873
  "data-testid": `connector-disconnect-${entry.slug}`,
1874
+ title: DISCONNECT_REVOKES_HINT,
1785
1875
  onClick: () => onDisconnect(entry),
1786
- disabled: isDisconnecting,
1876
+ disabled: isDisconnecting || isConnecting,
1787
1877
  className: "shrink-0 rounded-md px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50",
1788
1878
  children: isDisconnecting ? "Disconnecting\u2026" : "Disconnect"
1789
1879
  }
1790
1880
  ),
1881
+ canDisconnect && // Outcome-eval finding (DEV-7873): with no width cap, this
1882
+ // paragraph's one long sentence forced the WHOLE shrink-0
1883
+ // button column to grow to its natural (unwrapped) width,
1884
+ // squeezing the sibling `min-w-0 flex-1` text column down to
1885
+ // one word per line at any realistic tile width. RTL's jsdom
1886
+ // tests never caught it — jsdom doesn't lay out real flexbox
1887
+ // sizing, so every textContent assertion still passed. A
1888
+ // narrow max-width forces the hint to wrap within the same
1889
+ // footprint as the button above it instead.
1890
+ /* @__PURE__ */ jsx7(
1891
+ "p",
1892
+ {
1893
+ "data-testid": `connector-disconnect-hint-${entry.slug}`,
1894
+ className: "max-w-28 text-right text-[11px] text-muted-foreground",
1895
+ children: DISCONNECT_REVOKES_HINT
1896
+ }
1897
+ ),
1791
1898
  canManageSharing && /* @__PURE__ */ jsx7(
1792
1899
  "button",
1793
1900
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetreeve/ui",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Shared brand-adaptive UI primitives for MindFortress frontends — min-size tokens, a favicon→logo→monogram BrandGlyph, a responsive header that keeps the search bar usable on mobile, and AppSidebar's viewport-driven auto-collapse ladder.",
5
5
  "license": "UNLICENSED",
6
6
  "main": "./dist/index.js",