@meetreeve/ui 0.11.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
@@ -719,6 +719,19 @@ interface ConnectorCredentialTestResult {
719
719
  */
720
720
  permissions_note?: string | null;
721
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
+ }
722
735
  interface ConnectableEntry {
723
736
  slug: string;
724
737
  name: string;
@@ -749,6 +762,21 @@ interface ConnectableEntry {
749
762
  } | null;
750
763
  /** true = the viewer owns this connection (enables manage-sharing). */
751
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;
752
780
  };
753
781
  /** true = tenant has a live connection but the entry left the allowlist. */
754
782
  deprecated: boolean;
@@ -766,6 +794,15 @@ interface ConnectableEntry {
766
794
  * connect button renders, exactly as before.
767
795
  */
768
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;
769
806
  }
770
807
  interface ConnectorPickerClient {
771
808
  listConnectable(opts: {
@@ -829,4 +866,4 @@ interface ConnectorPickerProps {
829
866
  }
830
867
  declare function ConnectorPicker({ client, purpose, lens, layout, onConnected, }: ConnectorPickerProps): react.JSX.Element | null;
831
868
 
832
- 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
@@ -719,6 +719,19 @@ interface ConnectorCredentialTestResult {
719
719
  */
720
720
  permissions_note?: string | null;
721
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
+ }
722
735
  interface ConnectableEntry {
723
736
  slug: string;
724
737
  name: string;
@@ -749,6 +762,21 @@ interface ConnectableEntry {
749
762
  } | null;
750
763
  /** true = the viewer owns this connection (enables manage-sharing). */
751
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;
752
780
  };
753
781
  /** true = tenant has a live connection but the entry left the allowlist. */
754
782
  deprecated: boolean;
@@ -766,6 +794,15 @@ interface ConnectableEntry {
766
794
  * connect button renders, exactly as before.
767
795
  */
768
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;
769
806
  }
770
807
  interface ConnectorPickerClient {
771
808
  listConnectable(opts: {
@@ -829,4 +866,4 @@ interface ConnectorPickerProps {
829
866
  }
830
867
  declare function ConnectorPicker({ client, purpose, lens, layout, onConnected, }: ConnectorPickerProps): react.JSX.Element | null;
831
868
 
832
- 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
@@ -1425,6 +1425,8 @@ function ContextSwitcher({
1425
1425
  var import_react9 = require("react");
1426
1426
  var import_jsx_runtime7 = require("react/jsx-runtime");
1427
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.";
1428
1430
  var DEFAULT_DISCONNECT_ERROR = "Couldn't disconnect. Please try again.";
1429
1431
  var DEFAULT_LOAD_ERROR = "Couldn't load connections. Please try again.";
1430
1432
  var DEFAULT_GRANTS_LOAD_ERROR = "Couldn't load sharing. Please try again.";
@@ -1433,6 +1435,8 @@ var DEFAULT_REVOKE_ERROR = "Couldn't revoke access. Please try again.";
1433
1435
  var DEFAULT_ORG_DISABLED_REASON = "Org-wide connections aren't available to you.";
1434
1436
  var DEFAULT_CREDENTIAL_ERROR = "Couldn't save that credential. Please try again.";
1435
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.";
1436
1440
  var UNVERIFIABLE_CREDENTIAL_NOTE = "We can't verify this automatically \u2014 saving it is safe, but nothing has checked it yet.";
1437
1441
  var SKELETON_COUNT = 3;
1438
1442
  var UNGROUNDED_PLACEHOLDER = /\{\{[^}]*\}\}/;
@@ -1466,6 +1470,9 @@ function ownerLabel(entry) {
1466
1470
  function isSharedWithViewer(entry) {
1467
1471
  return entry.connection.status === "connected" && Boolean(entry.connection.shared_by) && !entry.connection.owned_by_viewer;
1468
1472
  }
1473
+ function isUnhealthy(entry) {
1474
+ return entry.connection.status === "connected" && entry.connection.health?.status === "unhealthy";
1475
+ }
1469
1476
  function ConnectorPicker({
1470
1477
  client,
1471
1478
  purpose,
@@ -1477,6 +1484,9 @@ function ConnectorPicker({
1477
1484
  const [entries, setEntries] = (0, import_react9.useState)(null);
1478
1485
  const [loadError, setLoadError] = (0, import_react9.useState)(null);
1479
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);
1480
1490
  const grantsSupported = typeof client.listGrants === "function" && typeof client.grant === "function" && typeof client.revokeGrant === "function";
1481
1491
  const credentialsSupported = typeof client.submitCredentials === "function";
1482
1492
  const credentialTestSupported = typeof client.testCredentials === "function";
@@ -1505,20 +1515,45 @@ function ConnectorPicker({
1505
1515
  void load();
1506
1516
  }, [gated, load, purpose]);
1507
1517
  const refreshAfterReturn = (0, import_react9.useCallback)(async () => {
1508
- const list = await load();
1509
- if (!list) return;
1510
- setUiState((prev) => {
1511
- let changed = false;
1512
- 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();
1513
1524
  for (const entry of list) {
1514
- if (next[entry.slug]?.kind === "connecting" && entry.connection.status === "connected") {
1515
- changed = true;
1516
- delete next[entry.slug];
1517
- onConnectedRef.current?.(entry);
1518
- }
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);
1519
1530
  }
1520
- return changed ? next : prev;
1521
- });
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
+ }
1522
1557
  }, [load]);
1523
1558
  (0, import_react9.useEffect)(() => {
1524
1559
  if (gated) return;
@@ -1539,6 +1574,12 @@ function ConnectorPicker({
1539
1574
  }, [gated, refreshAfterReturn]);
1540
1575
  const handleConnect = (0, import_react9.useCallback)(
1541
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);
1542
1583
  setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "connecting" } }));
1543
1584
  try {
1544
1585
  const result = await clientRef.current.connect(entry, { kind });
@@ -1548,6 +1589,8 @@ function ConnectorPicker({
1548
1589
  }
1549
1590
  const list = await load();
1550
1591
  const updated = list?.find((e) => e.slug === entry.slug) ?? null;
1592
+ reconnectingRef.current.delete(entry.slug);
1593
+ reconnectChecksRef.current.delete(entry.slug);
1551
1594
  setUiState((prev) => {
1552
1595
  const next = { ...prev };
1553
1596
  delete next[entry.slug];
@@ -1557,6 +1600,8 @@ function ConnectorPicker({
1557
1600
  onConnectedRef.current?.(updated);
1558
1601
  }
1559
1602
  } catch (err) {
1603
+ reconnectingRef.current.delete(entry.slug);
1604
+ reconnectChecksRef.current.delete(entry.slug);
1560
1605
  const message = errorMessageFrom(err, DEFAULT_CONNECT_ERROR);
1561
1606
  setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "error", message } }));
1562
1607
  }
@@ -1725,6 +1770,7 @@ function ConnectorTile({
1725
1770
  const isConnecting = ui?.kind === "connecting";
1726
1771
  const isDisconnecting = ui?.kind === "disconnecting";
1727
1772
  const errorMessage = ui?.kind === "error" ? ui.message : null;
1773
+ const unhealthy = isUnhealthy(entry);
1728
1774
  const [kind, setKind] = (0, import_react9.useState)("personal");
1729
1775
  const [sharingOpen, setSharingOpen] = (0, import_react9.useState)(false);
1730
1776
  const [setupOpen, setSetupOpen] = (0, import_react9.useState)(false);
@@ -1774,6 +1820,34 @@ function ConnectorTile({
1774
1820
  children: entry.connection.since ? `Connected since ${formatSince(entry.connection.since)}` : "Connected"
1775
1821
  }
1776
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
+ ),
1777
1851
  attribution && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1778
1852
  "p",
1779
1853
  {
@@ -1836,17 +1910,46 @@ function ConnectorTile({
1836
1910
  errorMessage && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { role: "alert", "data-testid": `connector-error-${entry.slug}`, className: "text-xs text-destructive", children: errorMessage })
1837
1911
  ] }),
1838
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
+ ),
1839
1924
  canDisconnect && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1840
1925
  "button",
1841
1926
  {
1842
1927
  type: "button",
1843
1928
  "data-testid": `connector-disconnect-${entry.slug}`,
1929
+ title: DISCONNECT_REVOKES_HINT,
1844
1930
  onClick: () => onDisconnect(entry),
1845
- disabled: isDisconnecting,
1931
+ disabled: isDisconnecting || isConnecting,
1846
1932
  className: "shrink-0 rounded-md px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50",
1847
1933
  children: isDisconnecting ? "Disconnecting\u2026" : "Disconnect"
1848
1934
  }
1849
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
+ ),
1850
1953
  canManageSharing && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1851
1954
  "button",
1852
1955
  {
package/dist/index.mjs CHANGED
@@ -1370,6 +1370,8 @@ function ContextSwitcher({
1370
1370
  import { useCallback as useCallback4, useEffect as useEffect6, useRef as useRef5, useState as useState7 } from "react";
1371
1371
  import { Fragment as Fragment4, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1372
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.";
1373
1375
  var DEFAULT_DISCONNECT_ERROR = "Couldn't disconnect. Please try again.";
1374
1376
  var DEFAULT_LOAD_ERROR = "Couldn't load connections. Please try again.";
1375
1377
  var DEFAULT_GRANTS_LOAD_ERROR = "Couldn't load sharing. Please try again.";
@@ -1378,6 +1380,8 @@ var DEFAULT_REVOKE_ERROR = "Couldn't revoke access. Please try again.";
1378
1380
  var DEFAULT_ORG_DISABLED_REASON = "Org-wide connections aren't available to you.";
1379
1381
  var DEFAULT_CREDENTIAL_ERROR = "Couldn't save that credential. Please try again.";
1380
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.";
1381
1385
  var UNVERIFIABLE_CREDENTIAL_NOTE = "We can't verify this automatically \u2014 saving it is safe, but nothing has checked it yet.";
1382
1386
  var SKELETON_COUNT = 3;
1383
1387
  var UNGROUNDED_PLACEHOLDER = /\{\{[^}]*\}\}/;
@@ -1411,6 +1415,9 @@ function ownerLabel(entry) {
1411
1415
  function isSharedWithViewer(entry) {
1412
1416
  return entry.connection.status === "connected" && Boolean(entry.connection.shared_by) && !entry.connection.owned_by_viewer;
1413
1417
  }
1418
+ function isUnhealthy(entry) {
1419
+ return entry.connection.status === "connected" && entry.connection.health?.status === "unhealthy";
1420
+ }
1414
1421
  function ConnectorPicker({
1415
1422
  client,
1416
1423
  purpose,
@@ -1422,6 +1429,9 @@ function ConnectorPicker({
1422
1429
  const [entries, setEntries] = useState7(null);
1423
1430
  const [loadError, setLoadError] = useState7(null);
1424
1431
  const [uiState, setUiState] = useState7({});
1432
+ const reconnectingRef = useRef5(/* @__PURE__ */ new Set());
1433
+ const reconnectChecksRef = useRef5(/* @__PURE__ */ new Map());
1434
+ const refreshInFlightRef = useRef5(false);
1425
1435
  const grantsSupported = typeof client.listGrants === "function" && typeof client.grant === "function" && typeof client.revokeGrant === "function";
1426
1436
  const credentialsSupported = typeof client.submitCredentials === "function";
1427
1437
  const credentialTestSupported = typeof client.testCredentials === "function";
@@ -1450,20 +1460,45 @@ function ConnectorPicker({
1450
1460
  void load();
1451
1461
  }, [gated, load, purpose]);
1452
1462
  const refreshAfterReturn = useCallback4(async () => {
1453
- const list = await load();
1454
- if (!list) return;
1455
- setUiState((prev) => {
1456
- let changed = false;
1457
- 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();
1458
1469
  for (const entry of list) {
1459
- if (next[entry.slug]?.kind === "connecting" && entry.connection.status === "connected") {
1460
- changed = true;
1461
- delete next[entry.slug];
1462
- onConnectedRef.current?.(entry);
1463
- }
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);
1464
1475
  }
1465
- return changed ? next : prev;
1466
- });
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
+ }
1467
1502
  }, [load]);
1468
1503
  useEffect6(() => {
1469
1504
  if (gated) return;
@@ -1484,6 +1519,12 @@ function ConnectorPicker({
1484
1519
  }, [gated, refreshAfterReturn]);
1485
1520
  const handleConnect = useCallback4(
1486
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);
1487
1528
  setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "connecting" } }));
1488
1529
  try {
1489
1530
  const result = await clientRef.current.connect(entry, { kind });
@@ -1493,6 +1534,8 @@ function ConnectorPicker({
1493
1534
  }
1494
1535
  const list = await load();
1495
1536
  const updated = list?.find((e) => e.slug === entry.slug) ?? null;
1537
+ reconnectingRef.current.delete(entry.slug);
1538
+ reconnectChecksRef.current.delete(entry.slug);
1496
1539
  setUiState((prev) => {
1497
1540
  const next = { ...prev };
1498
1541
  delete next[entry.slug];
@@ -1502,6 +1545,8 @@ function ConnectorPicker({
1502
1545
  onConnectedRef.current?.(updated);
1503
1546
  }
1504
1547
  } catch (err) {
1548
+ reconnectingRef.current.delete(entry.slug);
1549
+ reconnectChecksRef.current.delete(entry.slug);
1505
1550
  const message = errorMessageFrom(err, DEFAULT_CONNECT_ERROR);
1506
1551
  setUiState((prev) => ({ ...prev, [entry.slug]: { kind: "error", message } }));
1507
1552
  }
@@ -1670,6 +1715,7 @@ function ConnectorTile({
1670
1715
  const isConnecting = ui?.kind === "connecting";
1671
1716
  const isDisconnecting = ui?.kind === "disconnecting";
1672
1717
  const errorMessage = ui?.kind === "error" ? ui.message : null;
1718
+ const unhealthy = isUnhealthy(entry);
1673
1719
  const [kind, setKind] = useState7("personal");
1674
1720
  const [sharingOpen, setSharingOpen] = useState7(false);
1675
1721
  const [setupOpen, setSetupOpen] = useState7(false);
@@ -1719,6 +1765,34 @@ function ConnectorTile({
1719
1765
  children: entry.connection.since ? `Connected since ${formatSince(entry.connection.since)}` : "Connected"
1720
1766
  }
1721
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
+ ),
1722
1796
  attribution && /* @__PURE__ */ jsx7(
1723
1797
  "p",
1724
1798
  {
@@ -1781,17 +1855,46 @@ function ConnectorTile({
1781
1855
  errorMessage && /* @__PURE__ */ jsx7("p", { role: "alert", "data-testid": `connector-error-${entry.slug}`, className: "text-xs text-destructive", children: errorMessage })
1782
1856
  ] }),
1783
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
+ ),
1784
1869
  canDisconnect && /* @__PURE__ */ jsx7(
1785
1870
  "button",
1786
1871
  {
1787
1872
  type: "button",
1788
1873
  "data-testid": `connector-disconnect-${entry.slug}`,
1874
+ title: DISCONNECT_REVOKES_HINT,
1789
1875
  onClick: () => onDisconnect(entry),
1790
- disabled: isDisconnecting,
1876
+ disabled: isDisconnecting || isConnecting,
1791
1877
  className: "shrink-0 rounded-md px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50",
1792
1878
  children: isDisconnecting ? "Disconnecting\u2026" : "Disconnect"
1793
1879
  }
1794
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
+ ),
1795
1898
  canManageSharing && /* @__PURE__ */ jsx7(
1796
1899
  "button",
1797
1900
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetreeve/ui",
3
- "version": "0.11.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",