@cosmoops/connect-sdk 0.7.0 → 0.15.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.
Files changed (154) hide show
  1. package/CHANGELOG.md +662 -0
  2. package/README.md +376 -124
  3. package/dist/cjs/cli/doctor.js +3 -3
  4. package/dist/cjs/cli/init.js +3 -3
  5. package/dist/cjs/client/components/access-gate.js +59 -5
  6. package/dist/cjs/client/components/ai-format.js +135 -0
  7. package/dist/cjs/client/components/ai-widgets.js +232 -0
  8. package/dist/cjs/client/components/app-shell.js +80 -45
  9. package/dist/cjs/client/components/apps-grid.js +3 -2
  10. package/dist/cjs/client/components/ask-ai.js +353 -0
  11. package/dist/cjs/client/components/confirm-dialog.js +40 -0
  12. package/dist/cjs/client/components/cursor-pagination.js +22 -0
  13. package/dist/cjs/client/components/filter-tabs.js +24 -0
  14. package/dist/cjs/client/components/grievance-notice.js +30 -0
  15. package/dist/cjs/client/components/link-button.js +17 -10
  16. package/dist/cjs/client/components/logo.js +4 -3
  17. package/dist/cjs/client/components/nav.js +42 -1
  18. package/dist/cjs/client/components/plan-pricing.js +27 -0
  19. package/dist/cjs/client/components/plans.js +1 -1
  20. package/dist/cjs/client/components/platform-gate.js +48 -0
  21. package/dist/cjs/client/components/shell-context.js +149 -0
  22. package/dist/cjs/client/components/side-nav.js +50 -24
  23. package/dist/cjs/client/components/sidebar.js +127 -0
  24. package/dist/cjs/client/components/subscribe-dialog.js +2 -2
  25. package/dist/cjs/client/components/subscription.js +1 -1
  26. package/dist/cjs/client/components/user-menu.js +122 -40
  27. package/dist/cjs/client/components/workspace-switcher.js +100 -0
  28. package/dist/cjs/client/components/workspace-type-gate.js +48 -0
  29. package/dist/cjs/client/index.js +98 -3
  30. package/dist/cjs/client/provider.js +100 -2
  31. package/dist/cjs/client/use-checkout.js +9 -5
  32. package/dist/cjs/errors.js +1 -1
  33. package/dist/cjs/index.js +11 -1
  34. package/dist/cjs/next/index.js +2 -2
  35. package/dist/cjs/server/client.js +25 -2
  36. package/dist/cjs/server/handler.js +237 -16
  37. package/dist/cjs/server/http.js +52 -26
  38. package/dist/cjs/server/resources/acting-as.js +16 -0
  39. package/dist/cjs/server/resources/ai.js +15 -2
  40. package/dist/cjs/server/resources/data.js +374 -0
  41. package/dist/cjs/server/resources/entitlements.js +41 -0
  42. package/dist/cjs/server/resources/identity.js +9 -3
  43. package/dist/cjs/server/resources/kv.js +84 -0
  44. package/dist/cjs/server/resources/platform.js +43 -0
  45. package/dist/cjs/server/resources/sessions.js +1 -1
  46. package/dist/cjs/server/resources/workspaces.js +74 -0
  47. package/dist/cjs/store.js +152 -0
  48. package/dist/cjs/ui/empty-state.js +28 -0
  49. package/dist/cjs/ui/index.js +44 -0
  50. package/dist/cjs/ui/page-header.js +27 -0
  51. package/dist/cjs/ui/page-shell.js +44 -0
  52. package/dist/cjs/ui/section.js +20 -0
  53. package/dist/cjs/ui/setting-row.js +14 -0
  54. package/dist/cjs/ui/status-chip.js +17 -0
  55. package/dist/esm/cli/doctor.js +3 -3
  56. package/dist/esm/cli/init.js +3 -3
  57. package/dist/esm/client/components/access-gate.js +60 -6
  58. package/dist/esm/client/components/ai-format.js +129 -0
  59. package/dist/esm/client/components/ai-widgets.js +223 -0
  60. package/dist/esm/client/components/app-shell.js +85 -50
  61. package/dist/esm/client/components/apps-grid.js +3 -2
  62. package/dist/esm/client/components/ask-ai.js +350 -0
  63. package/dist/esm/client/components/confirm-dialog.js +37 -0
  64. package/dist/esm/client/components/cursor-pagination.js +19 -0
  65. package/dist/esm/client/components/filter-tabs.js +21 -0
  66. package/dist/esm/client/components/grievance-notice.js +27 -0
  67. package/dist/esm/client/components/link-button.js +17 -10
  68. package/dist/esm/client/components/logo.js +4 -3
  69. package/dist/esm/client/components/nav.js +41 -1
  70. package/dist/esm/client/components/plan-pricing.js +25 -0
  71. package/dist/esm/client/components/plans.js +2 -2
  72. package/dist/esm/client/components/platform-gate.js +45 -0
  73. package/dist/esm/client/components/shell-context.js +141 -0
  74. package/dist/esm/client/components/side-nav.js +49 -22
  75. package/dist/esm/client/components/sidebar.js +120 -0
  76. package/dist/esm/client/components/subscribe-dialog.js +3 -3
  77. package/dist/esm/client/components/subscription.js +2 -2
  78. package/dist/esm/client/components/user-menu.js +121 -41
  79. package/dist/esm/client/components/workspace-switcher.js +97 -0
  80. package/dist/esm/client/components/workspace-type-gate.js +45 -0
  81. package/dist/esm/client/index.js +58 -5
  82. package/dist/esm/client/provider.js +94 -2
  83. package/dist/esm/client/use-checkout.js +9 -5
  84. package/dist/esm/errors.js +1 -1
  85. package/dist/esm/index.js +1 -0
  86. package/dist/esm/next/index.js +2 -2
  87. package/dist/esm/server/client.js +25 -2
  88. package/dist/esm/server/handler.js +237 -16
  89. package/dist/esm/server/http.js +52 -26
  90. package/dist/esm/server/resources/acting-as.js +13 -0
  91. package/dist/esm/server/resources/ai.js +15 -2
  92. package/dist/esm/server/resources/data.js +369 -0
  93. package/dist/esm/server/resources/entitlements.js +41 -0
  94. package/dist/esm/server/resources/identity.js +9 -3
  95. package/dist/esm/server/resources/kv.js +80 -0
  96. package/dist/esm/server/resources/platform.js +39 -0
  97. package/dist/esm/server/resources/sessions.js +1 -1
  98. package/dist/esm/server/resources/workspaces.js +70 -0
  99. package/dist/esm/store.js +141 -0
  100. package/dist/esm/ui/empty-state.js +25 -0
  101. package/dist/esm/ui/index.js +34 -0
  102. package/dist/esm/ui/page-header.js +24 -0
  103. package/dist/esm/ui/page-shell.js +41 -0
  104. package/dist/esm/ui/section.js +17 -0
  105. package/dist/esm/ui/setting-row.js +11 -0
  106. package/dist/esm/ui/status-chip.js +14 -0
  107. package/dist/theme.css +87 -0
  108. package/dist/types/client/components/access-gate.d.ts +17 -3
  109. package/dist/types/client/components/ai-format.d.ts +24 -0
  110. package/dist/types/client/components/ai-widgets.d.ts +42 -0
  111. package/dist/types/client/components/app-shell.d.ts +85 -31
  112. package/dist/types/client/components/apps-grid.d.ts +11 -2
  113. package/dist/types/client/components/ask-ai.d.ts +73 -0
  114. package/dist/types/client/components/confirm-dialog.d.ts +34 -0
  115. package/dist/types/client/components/cursor-pagination.d.ts +27 -0
  116. package/dist/types/client/components/filter-tabs.d.ts +26 -0
  117. package/dist/types/client/components/grievance-notice.d.ts +31 -0
  118. package/dist/types/client/components/link-button.d.ts +32 -9
  119. package/dist/types/client/components/logo.d.ts +18 -2
  120. package/dist/types/client/components/nav.d.ts +27 -3
  121. package/dist/types/client/components/plan-pricing.d.ts +10 -0
  122. package/dist/types/client/components/platform-gate.d.ts +53 -0
  123. package/dist/types/client/components/shell-context.d.ts +116 -0
  124. package/dist/types/client/components/side-nav.d.ts +28 -14
  125. package/dist/types/client/components/sidebar.d.ts +95 -0
  126. package/dist/types/client/components/user-menu.d.ts +70 -23
  127. package/dist/types/client/components/workspace-switcher.d.ts +49 -0
  128. package/dist/types/client/components/workspace-type-gate.d.ts +48 -0
  129. package/dist/types/client/index.d.ts +36 -7
  130. package/dist/types/client/provider.d.ts +149 -3
  131. package/dist/types/client/use-checkout.d.ts +8 -4
  132. package/dist/types/errors.d.ts +1 -1
  133. package/dist/types/index.d.ts +2 -0
  134. package/dist/types/next/index.d.ts +1 -1
  135. package/dist/types/server/client.d.ts +25 -2
  136. package/dist/types/server/http.d.ts +14 -3
  137. package/dist/types/server/index.d.ts +4 -0
  138. package/dist/types/server/resources/acting-as.d.ts +11 -0
  139. package/dist/types/server/resources/ai.d.ts +9 -1
  140. package/dist/types/server/resources/data.d.ts +258 -0
  141. package/dist/types/server/resources/entitlements.d.ts +33 -1
  142. package/dist/types/server/resources/kv.d.ts +63 -0
  143. package/dist/types/server/resources/platform.d.ts +32 -0
  144. package/dist/types/server/resources/workspaces.d.ts +55 -0
  145. package/dist/types/store.d.ts +96 -0
  146. package/dist/types/types.d.ts +491 -13
  147. package/dist/types/ui/empty-state.d.ts +34 -0
  148. package/dist/types/ui/index.d.ts +40 -0
  149. package/dist/types/ui/page-header.d.ts +34 -0
  150. package/dist/types/ui/page-shell.d.ts +39 -0
  151. package/dist/types/ui/section.d.ts +26 -0
  152. package/dist/types/ui/setting-row.d.ts +18 -0
  153. package/dist/types/ui/status-chip.d.ts +36 -0
  154. package/package.json +13 -3
@@ -162,8 +162,8 @@ function checkEnv(root) {
162
162
  status: "fail",
163
163
  title: `${name} set`,
164
164
  detail: declaredEmpty
165
- ? `Declared but empty in .env.local. The value is in your application's Integration tab in Connect.`
166
- : `Not found in .env.local or the environment. Both values are in your application's Integration tab in Connect.`,
165
+ ? `Declared but empty in .env.local. The value is in your application's Connect client section in Connect.`
166
+ : `Not found in .env.local or the environment. Both values are in your application's Connect client section in Connect.`,
167
167
  };
168
168
  });
169
169
  }
@@ -176,7 +176,7 @@ function checkRedirectUri() {
176
176
  return {
177
177
  status: "warn",
178
178
  title: "Redirect URI registered in Connect",
179
- detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your application's Integration tab - including the deployed origin, not just http://localhost:3000.",
179
+ detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your application's Connect client section - including the deployed origin, not just http://localhost:3000.",
180
180
  };
181
181
  }
182
182
  const MARKER = { pass: " ok ", warn: " note ", fail: " FAIL " };
@@ -44,7 +44,7 @@ const PLANS_PAGE = `"use client";
44
44
  // with the other exports) whenever you want your own copy or layout.
45
45
  export { ConnectPlansPage as default } from "@cosmoops/connect-sdk/client";
46
46
  `;
47
- const ENV_TEMPLATE = `# CosmoOps Connect - values come from your application's Integration tab.
47
+ const ENV_TEMPLATE = `# CosmoOps Connect - values come from your application's Connect client section.
48
48
  CONNECT_API_KEY=
49
49
  CONNECT_CLIENT_ID=
50
50
  `;
@@ -111,7 +111,7 @@ function runInit(argv = []) {
111
111
  console.log(` ${marker} ${(0, node_path_1.relative)(root, target)} - ${scaffold.description}`);
112
112
  }
113
113
  const env = ensureEnv(root);
114
- console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your application's Integration tab`);
114
+ console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your application's Connect client section`);
115
115
  // The two steps a scaffolder must not do for you: editing an existing layout
116
116
  // means parsing and rewriting JSX someone else wrote, and the redirect URI
117
117
  // lives in Connect, not in this repo.
@@ -137,7 +137,7 @@ Three things left, all one-time:
137
137
  Tailwind roots its content scan inside node_modules and skips your app.
138
138
 
139
139
  3. Register {your origin}/api/connect/callback as a redirect URI in your
140
- application's Integration tab in Connect.
140
+ application's Connect client section in Connect.
141
141
 
142
142
  No next.config.ts changes needed - not even transpilePackages: dist/ is
143
143
  already ES2022 with "use client" hoisted, so Turbopack and webpack consume it
@@ -25,19 +25,46 @@ function formatDate(iso) {
25
25
  * `ConnectReadinessGate`/`ConnectReadinessBanner` already use. `ConnectAccessGate`
26
26
  * is a priority ordering over the same data, not a second opinion about it.
27
27
  *
28
- * Priority, first match wins: `suspended` > `no_subscription` >
29
- * `onboarding_pending` > any other blocking code (`blocked`) >
30
- * `payment_retry` (advisory - `children` still render) > `clear`.
28
+ * Priority, first match wins: `suspended` > `audience_mismatch` >
29
+ * `no_subscription` > `onboarding_pending` > any other blocking code
30
+ * (`blocked`) > `payment_retry` (advisory - `children` still render) >
31
+ * `clear`.
32
+ *
33
+ * `audience_mismatch` outranks `no_subscription` deliberately, and the
34
+ * ordering is the whole point of it: a workspace of the wrong account type
35
+ * carries BOTH blockers at once, and showing the subscription one first sent
36
+ * the customer to a plan picker whose subscribe call
37
+ * (`ActivateSubscriptionUseCase`) rejects them for the audience anyway. The
38
+ * account type is the reason; the missing subscription is a consequence.
31
39
  */
32
40
  function useConnectAccessGate() {
33
41
  const { session, isLoading: sessionLoading } = (0, provider_js_1.useConnect)();
34
42
  const { data, isLoading: entitlementsLoading } = (0, provider_js_1.useConnectEntitlements)();
35
- if (sessionLoading || (session?.user && entitlementsLoading)) {
43
+ if (sessionLoading) {
36
44
  return { state: "loading", blocker: null, paymentRetry: null };
37
45
  }
38
46
  if (!session?.user) {
39
47
  return { state: "signed_out", blocker: null, paymentRetry: null };
40
48
  }
49
+ // Connect platform staff pass straight through, and this check has to come
50
+ // *before* the entitlements read below or the gate would hard-block them.
51
+ //
52
+ // Every state this gate can report is a fact about a workspace - suspended,
53
+ // no plan, wrong audience, awaiting approval, payment due. A staff session
54
+ // has no workspace at all (see `SessionAccess.staff`), so it has no subscription
55
+ // either, and `subscription_missing` would send an operator to your plan
56
+ // picker to buy a plan for a workspace that does not exist. "Nothing is
57
+ // blocking this session" is the truthful answer, not a lenient one.
58
+ //
59
+ // The entitlements request is still issued - hooks cannot be called
60
+ // conditionally - and the bridge answers it `403 no_workspace`. That costs
61
+ // one failed request per staff page load, and its result is ignored here.
62
+ if (session.staff) {
63
+ return { state: "clear", blocker: null, paymentRetry: null };
64
+ }
65
+ if (entitlementsLoading) {
66
+ return { state: "loading", blocker: null, paymentRetry: null };
67
+ }
41
68
  if (!data) {
42
69
  // Signed in, not loading, yet nothing came back - a failed read (see
43
70
  // `error` on `useConnectEntitlements`). Fail open rather than hard-block
@@ -50,6 +77,10 @@ function useConnectAccessGate() {
50
77
  const suspended = blocking.find((b) => b.code === "workspace_suspended");
51
78
  if (suspended)
52
79
  return { state: "suspended", blocker: suspended, paymentRetry: null };
80
+ const audienceMismatch = blocking.find((b) => b.code === "audience_mismatch");
81
+ if (audienceMismatch) {
82
+ return { state: "audience_mismatch", blocker: audienceMismatch, paymentRetry: null };
83
+ }
53
84
  const noSubscription = blocking.find((b) => b.code === "subscription_missing");
54
85
  if (noSubscription)
55
86
  return { state: "no_subscription", blocker: noSubscription, paymentRetry: null };
@@ -109,6 +140,29 @@ function ConnectAccessGate({ children, fallback, loading, className }) {
109
140
  return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: fallback ?? (0, jsx_runtime_1.jsx)(AccessGateFallback, { state: gate.state, blocker: gate.blocker, className: className }) }));
110
141
  }
111
142
  function AccessGateFallback({ state, blocker, className, }) {
143
+ if (state === "audience_mismatch") {
144
+ // `requiredWorkspaceType` rather than the message, which is documented as
145
+ // human-facing and subject to rewording - the title has to name the right
146
+ // account type even after someone rephrases the sentence below it.
147
+ const required = blocker?.requiredWorkspaceType ?? null;
148
+ return ((0, jsx_runtime_1.jsx)(GateCard, { icon: required === "individual" ? lucide_react_1.User : lucide_react_1.Building2, iconClassName: "text-muted", title: required === "business"
149
+ ? "Business account required"
150
+ : required === "individual"
151
+ ? "Individual account required"
152
+ : "Not available for this account type",
153
+ // The server names the application in its own message, which reads
154
+ // better than anything composable here; the fallback covers a blocker
155
+ // that arrived without one.
156
+ message: blocker?.message ??
157
+ (required
158
+ ? `This application is only available to ${required} accounts.`
159
+ : "This application isn't available to this account's type."),
160
+ // Deliberately no action, even if one were sent: `Workspace.type` is
161
+ // set at sign-up and no screen changes it, so any button here would
162
+ // only move the dead end somewhere else. Switching means a different
163
+ // account, which is a conversation with support, not a link.
164
+ resolveUrl: null, className: className }));
165
+ }
112
166
  if (state === "no_subscription") {
113
167
  return ((0, jsx_runtime_1.jsx)(GateCard, { icon: lucide_react_1.CreditCard, iconClassName: "text-accent", title: "Choose a plan to continue", message: blocker?.message ?? "This workspace isn't subscribed to this application yet.", actionLabel: "See plans", resolveUrl: blocker?.resolveUrl ?? null, className: className }));
114
168
  }
@@ -118,7 +172,7 @@ function AccessGateFallback({ state, blocker, className, }) {
118
172
  if (state === "onboarding_pending") {
119
173
  return ((0, jsx_runtime_1.jsx)(GateCard, { icon: lucide_react_1.Clock, iconClassName: "text-muted", title: "Waiting on approval", message: blocker?.message ?? "This workspace is waiting on approval before it can use this application.", resolveUrl: null, className: className }));
120
174
  }
121
- // "blocked" - any other blocking code (verification, audience, seats, ...).
175
+ // "blocked" - any other blocking code (verification, seats, ...).
122
176
  // Full fidelity for a code this component doesn't special-case: the same
123
177
  // banner-in-a-card `ConnectReadinessGate` falls back to, rather than
124
178
  // `GateCard`'s single-blocker shape - there can be more than one of these
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AI_TONE_DOT = exports.AI_TONE_CHIP = exports.AI_EMPTY_VALUE = void 0;
4
+ exports.formatAiValue = formatAiValue;
5
+ exports.isNumericAiFormat = isNumericAiFormat;
6
+ exports.aiStatusTone = aiStatusTone;
7
+ /**
8
+ * The one place a model-supplied value becomes a string.
9
+ *
10
+ * Ask AI's presentation tools send raw values with a `format` tag beside them
11
+ * rather than pre-formatted strings (see `AiValueFormat`), and this is the half
12
+ * that makes that worth doing: an amount renders in the viewer's own locale, a
13
+ * table sorts numerically, and the same figure reads identically on a chart
14
+ * axis, in a tooltip and in a metric tile.
15
+ *
16
+ * Every branch is total. A value that cannot be formatted the way it was
17
+ * tagged - a `date` that is not a date, a `currency` that is not a number -
18
+ * falls back to its plain string rather than throwing inside a render.
19
+ */
20
+ /** Renders in place of a missing value - an em dash, not the word "null". */
21
+ exports.AI_EMPTY_VALUE = "—";
22
+ const NUMBER = new Intl.NumberFormat("en-IN");
23
+ const PERCENT = new Intl.NumberFormat("en-IN", { style: "percent", maximumFractionDigits: 1 });
24
+ /**
25
+ * Rupees, not paise - the tool contract says so, because the model is emitting
26
+ * a figure a person would say out loud. `formatPrice` in `plan-pricing.ts` is
27
+ * the paise-denominated twin, for amounts that came out of Connect's billing.
28
+ */
29
+ const CURRENCY = new Intl.NumberFormat("en-IN", {
30
+ style: "currency",
31
+ currency: "INR",
32
+ maximumFractionDigits: 2,
33
+ });
34
+ const DATE = new Intl.DateTimeFormat("en-IN", { day: "numeric", month: "short", year: "numeric" });
35
+ const DATE_TIME = new Intl.DateTimeFormat("en-IN", {
36
+ day: "numeric",
37
+ month: "short",
38
+ year: "numeric",
39
+ hour: "numeric",
40
+ minute: "2-digit",
41
+ });
42
+ function formatBytes(bytes) {
43
+ const units = ["B", "KB", "MB", "GB", "TB"];
44
+ let value = bytes;
45
+ let unit = 0;
46
+ while (value >= 1024 && unit < units.length - 1) {
47
+ value /= 1024;
48
+ unit++;
49
+ }
50
+ return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
51
+ }
52
+ function formatDuration(seconds) {
53
+ if (seconds < 60)
54
+ return `${Math.round(seconds)}s`;
55
+ const minutes = Math.floor(seconds / 60);
56
+ if (minutes < 60)
57
+ return `${minutes}m`;
58
+ const hours = Math.floor(minutes / 60);
59
+ const remainder = minutes % 60;
60
+ if (hours < 24)
61
+ return remainder ? `${hours}h ${remainder}m` : `${hours}h`;
62
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
63
+ }
64
+ function asDate(value) {
65
+ if (typeof value !== "string" && typeof value !== "number")
66
+ return null;
67
+ const date = new Date(value);
68
+ return Number.isNaN(date.getTime()) ? null : date;
69
+ }
70
+ function formatAiValue(value, format = "text") {
71
+ if (value === null || value === undefined || value === "")
72
+ return exports.AI_EMPTY_VALUE;
73
+ switch (format) {
74
+ case "number":
75
+ return typeof value === "number" ? NUMBER.format(value) : String(value);
76
+ case "currency":
77
+ return typeof value === "number" ? CURRENCY.format(value) : String(value);
78
+ case "percent":
79
+ return typeof value === "number" ? PERCENT.format(value) : String(value);
80
+ case "bytes":
81
+ return typeof value === "number" ? formatBytes(value) : String(value);
82
+ case "duration":
83
+ return typeof value === "number" ? formatDuration(value) : String(value);
84
+ case "date": {
85
+ const date = asDate(value);
86
+ return date ? DATE.format(date) : String(value);
87
+ }
88
+ case "datetime": {
89
+ const date = asDate(value);
90
+ return date ? DATE_TIME.format(date) : String(value);
91
+ }
92
+ default:
93
+ return String(value);
94
+ }
95
+ }
96
+ /** Numeric columns read far better right-aligned; everything else stays with the text. */
97
+ function isNumericAiFormat(format = "text") {
98
+ return (format === "number" ||
99
+ format === "currency" ||
100
+ format === "percent" ||
101
+ format === "bytes" ||
102
+ format === "duration");
103
+ }
104
+ /**
105
+ * A status word to a tone. A small keyword list rather than an enum on the
106
+ * wire, because the model names states from the partner's own domain
107
+ * ("provisioning", "in transit") - and anything unrecognised is neutral, which
108
+ * is the right answer for a word nobody can interpret.
109
+ */
110
+ const POSITIVE = new Set(["active", "paid", "succeeded", "success", "complete", "completed", "approved", "healthy", "live", "enabled", "delivered", "on", "yes"]);
111
+ const WARNING = new Set(["pending", "processing", "trialing", "trial", "grace", "review", "queued", "partial", "draft", "in_transit"]);
112
+ const DANGER = new Set(["failed", "error", "overdue", "suspended", "cancelled", "canceled", "rejected", "expired", "past_due", "blocked", "disabled", "off"]);
113
+ function aiStatusTone(value) {
114
+ const word = String(value ?? "").trim().toLowerCase().replace(/\s+/g, "_");
115
+ if (POSITIVE.has(word))
116
+ return "positive";
117
+ if (WARNING.has(word))
118
+ return "warning";
119
+ if (DANGER.has(word))
120
+ return "danger";
121
+ return "neutral";
122
+ }
123
+ /** HeroUI semantic tokens only, so these inherit whatever theme the host defined. */
124
+ exports.AI_TONE_CHIP = {
125
+ neutral: "bg-surface-tertiary text-foreground",
126
+ positive: "bg-success/15 text-success",
127
+ warning: "bg-warning/20 text-warning",
128
+ danger: "bg-danger/15 text-danger",
129
+ };
130
+ exports.AI_TONE_DOT = {
131
+ neutral: "bg-muted",
132
+ positive: "bg-success",
133
+ warning: "bg-warning",
134
+ danger: "bg-danger",
135
+ };
@@ -0,0 +1,232 @@
1
+ "use client";
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.AiTable = AiTable;
5
+ exports.AiChart = AiChart;
6
+ exports.AiMetrics = AiMetrics;
7
+ exports.AiTimeline = AiTimeline;
8
+ exports.AiDetails = AiDetails;
9
+ exports.AiSteps = AiSteps;
10
+ exports.AiSources = AiSources;
11
+ const jsx_runtime_1 = require("react/jsx-runtime");
12
+ const react_1 = require("react");
13
+ const react_2 = require("@heroui/react");
14
+ const lucide_react_1 = require("lucide-react");
15
+ const ai_format_js_1 = require("./ai-format.js");
16
+ /**
17
+ * What Ask AI draws.
18
+ *
19
+ * Seven widgets, one per data shape, rendered from the model's own tool call -
20
+ * see `AiToolName`. Everything here is HeroUI semantic tokens and inline SVG:
21
+ * **no charting library, no table library, no new dependency**, because this
22
+ * package ships to partners and a chart library is not a reasonable thing to
23
+ * make every application platform install for one panel.
24
+ *
25
+ * The hand-rolled chart is the deliberate part of that. A 400px side panel does
26
+ * not need pan, zoom, brushes or animation; it needs axis labels a human can
27
+ * read and a line that goes where the numbers do. That is ~80 lines of SVG.
28
+ */
29
+ /** The four small panels share one frame, so an answer with two of them reads as one object. */
30
+ function Panel({ title, children }) {
31
+ return ((0, jsx_runtime_1.jsxs)("figure", { className: "my-3 rounded-lg border border-border p-3", children: [title && (0, jsx_runtime_1.jsx)("figcaption", { className: "mb-2 text-xs font-semibold", children: title }), children] }));
32
+ }
33
+ const TONE_TEXT = {
34
+ neutral: "text-muted",
35
+ positive: "text-success",
36
+ warning: "text-warning",
37
+ danger: "text-danger",
38
+ };
39
+ function StatusChip({ value }) {
40
+ return ((0, jsx_runtime_1.jsx)("span", { className: `inline-block rounded-full px-1.5 py-0.5 text-[10px] font-medium ${ai_format_js_1.AI_TONE_CHIP[(0, ai_format_js_1.aiStatusTone)(value)]}`, children: String(value) }));
41
+ }
42
+ /* ------------------------------------------------------------------ table */
43
+ /**
44
+ * `showTable`. Sorting and CSV are what a table is *for* - once an answer is
45
+ * tabular the next thing anyone wants is to reorder it or take it elsewhere,
46
+ * and a markdown table can do neither. Both work off the raw values, so
47
+ * "₹12,500" sorts as 12500 and the export carries numbers a spreadsheet can add.
48
+ */
49
+ function AiTable({ title, columns, rows, caption }) {
50
+ // By column *index*: rows are positional (see `AiTableInput`), so the index
51
+ // is the only identity a column has.
52
+ const [sort, setSort] = (0, react_1.useState)(null);
53
+ const sorted = (0, react_1.useMemo)(() => {
54
+ if (!sort)
55
+ return rows;
56
+ const direction = sort.direction === "asc" ? 1 : -1;
57
+ return [...rows].sort((a, b) => {
58
+ const left = a[sort.column];
59
+ const right = b[sort.column];
60
+ // Missing values sink in both directions. The alternative is empty rows
61
+ // migrating to the top when you reverse a column, which reads as data
62
+ // appearing out of nowhere.
63
+ if (left === null || left === undefined)
64
+ return 1;
65
+ if (right === null || right === undefined)
66
+ return -1;
67
+ if (typeof left === "number" && typeof right === "number")
68
+ return (left - right) * direction;
69
+ return String(left).localeCompare(String(right)) * direction;
70
+ });
71
+ }, [rows, sort]);
72
+ const downloadCsv = () => {
73
+ const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`;
74
+ const csv = [
75
+ columns.map((column) => escape(column.label)).join(","),
76
+ ...sorted.map((row) => columns.map((_column, index) => escape(row[index])).join(",")),
77
+ ].join("\n");
78
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
79
+ const link = document.createElement("a");
80
+ link.href = url;
81
+ link.download = `${(title ?? "table").toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}.csv`;
82
+ link.click();
83
+ URL.revokeObjectURL(url);
84
+ };
85
+ const toggleSort = (column) => setSort((current) => current?.column === column
86
+ ? current.direction === "asc"
87
+ ? { column, direction: "desc" }
88
+ : null
89
+ : { column, direction: "asc" });
90
+ return ((0, jsx_runtime_1.jsxs)("figure", { className: "my-3 overflow-hidden rounded-lg border border-border", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 border-b border-border bg-surface-secondary px-3 py-2", children: [(0, jsx_runtime_1.jsx)("figcaption", { className: "flex-1 truncate text-xs font-semibold", children: title ?? "Results" }), (0, jsx_runtime_1.jsxs)("span", { className: "text-[11px] text-muted", children: [rows.length, " rows"] }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Download as CSV", onPress: downloadCsv, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Download, { className: "size-3.5" }) })] }), (0, jsx_runtime_1.jsx)("div", { className: "overflow-x-auto", children: (0, jsx_runtime_1.jsxs)("table", { className: "w-full text-xs", children: [(0, jsx_runtime_1.jsx)("thead", { children: (0, jsx_runtime_1.jsx)("tr", { className: "border-b border-border", children: columns.map((column, columnIndex) => {
91
+ const isSorted = sort?.column === columnIndex;
92
+ const Icon = !isSorted ? lucide_react_1.ChevronsUpDown : sort.direction === "asc" ? lucide_react_1.ArrowUp : lucide_react_1.ArrowDown;
93
+ return ((0, jsx_runtime_1.jsx)("th", { scope: "col", className: "p-0", children: (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => toggleSort(columnIndex), "aria-label": `Sort by ${column.label}`, className: `flex w-full items-center gap-1 px-2.5 py-1.5 font-medium whitespace-nowrap hover:bg-surface-secondary ${(0, ai_format_js_1.isNumericAiFormat)(column.format) ? "justify-end" : "justify-start"}`, children: [column.label, (0, jsx_runtime_1.jsx)(Icon, { className: `size-3 shrink-0 ${isSorted ? "text-foreground" : "text-muted/60"}` })] }) }, columnIndex));
94
+ }) }) }), (0, jsx_runtime_1.jsxs)("tbody", { children: [sorted.map((row, index) => ((0, jsx_runtime_1.jsx)("tr", { className: "border-b border-border/60 last:border-b-0", children: columns.map((column, columnIndex) => {
95
+ const value = row[columnIndex] ?? null;
96
+ return ((0, jsx_runtime_1.jsx)("td", { className: `px-2.5 py-1.5 align-top ${(0, ai_format_js_1.isNumericAiFormat)(column.format) ? "text-end tabular-nums" : "text-start"}`, children: column.format === "status" && value !== null ? ((0, jsx_runtime_1.jsx)(StatusChip, { value: value })) : ((0, jsx_runtime_1.jsx)("span", { className: value === null ? "text-muted" : undefined, children: (0, ai_format_js_1.formatAiValue)(value, column.format) })) }, columnIndex));
97
+ }) }, index))), sorted.length === 0 && ((0, jsx_runtime_1.jsx)("tr", { children: (0, jsx_runtime_1.jsx)("td", { colSpan: columns.length, className: "px-2.5 py-3 text-center text-muted", children: ai_format_js_1.AI_EMPTY_VALUE }) }))] })] }) }), caption && (0, jsx_runtime_1.jsx)("p", { className: "border-t border-border px-3 py-1.5 text-[11px] text-muted", children: caption })] }));
98
+ }
99
+ /* ------------------------------------------------------------------ chart */
100
+ /**
101
+ * The theme's categorical ramp. Series identity is carried by the legend as
102
+ * well as the hue, never by hue alone.
103
+ */
104
+ const SERIES_COLORS = [
105
+ "var(--chart-cat-1, var(--accent))",
106
+ "var(--chart-cat-2, var(--warning))",
107
+ "var(--chart-cat-3, var(--success))",
108
+ "var(--chart-cat-4, var(--danger))",
109
+ "var(--chart-cat-5, var(--muted))",
110
+ ];
111
+ const VIEW_W = 320;
112
+ const VIEW_H = 140;
113
+ const PAD = { top: 8, right: 8, bottom: 20, left: 44 };
114
+ /**
115
+ * `showChart`. Cartesian kinds are drawn as SVG against a fixed viewBox and
116
+ * scaled by the browser, which is why there is no resize observer here: the
117
+ * chart is resolution-independent, so "responsive" costs nothing at runtime.
118
+ *
119
+ * `pie` is deliberately *not* a pie. In a 400px column a pie plus its legend
120
+ * is smaller than the labels it needs, and reading angles is worse than
121
+ * reading lengths at any size - so proportions render as a labelled bar list,
122
+ * which answers the same question and stays legible.
123
+ */
124
+ function AiChart({ title, kind, xLabel, yLabel, valueFormat, series, caption }) {
125
+ const format = (value) => (0, ai_format_js_1.formatAiValue)(value, valueFormat);
126
+ const axis = series[0]?.points.map((point) => point.x) ?? [];
127
+ // Which x the pointer is nearest. An SVG chart is an interactive chart:
128
+ // without this the exact values are unreadable, because a 140px-tall plot
129
+ // can only carry three axis labels.
130
+ const [hovered, setHovered] = (0, react_1.useState)(null);
131
+ if (kind === "pie") {
132
+ const slices = series[0]?.points ?? [];
133
+ // Guards a divide-by-zero on an all-zero set, which would otherwise make
134
+ // every bar `NaN%` wide.
135
+ const total = slices.reduce((sum, point) => sum + (point.y ?? 0), 0) || 1;
136
+ return ((0, jsx_runtime_1.jsxs)(Panel, { title: title, children: [(0, jsx_runtime_1.jsx)("ul", { className: "space-y-1.5", children: slices.map((slice, index) => ((0, jsx_runtime_1.jsxs)("li", { className: "text-xs", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-baseline justify-between gap-2", children: [(0, jsx_runtime_1.jsx)("span", { className: "truncate", children: slice.x }), (0, jsx_runtime_1.jsxs)("span", { className: "shrink-0 tabular-nums text-muted", children: [format(slice.y ?? 0), " \u00B7 ", Math.round(((slice.y ?? 0) / total) * 100), "%"] })] }), (0, jsx_runtime_1.jsx)("div", { className: "mt-0.5 h-1.5 overflow-hidden rounded-full bg-surface-tertiary", children: (0, jsx_runtime_1.jsx)("div", { className: "h-full rounded-full", style: {
137
+ width: `${((slice.y ?? 0) / total) * 100}%`,
138
+ backgroundColor: SERIES_COLORS[index % SERIES_COLORS.length],
139
+ } }) })] }, slice.x))) }), caption && (0, jsx_runtime_1.jsx)("p", { className: "mt-2 text-[11px] text-muted", children: caption })] }));
140
+ }
141
+ const values = series.flatMap((entry) => entry.points.map((point) => point.y)).filter((y) => y !== null);
142
+ // A flat series would give a zero-height range and divide by zero; padding
143
+ // the top by 1 draws it as a line across the middle instead of NaN.
144
+ const max = values.length ? Math.max(...values, 0) : 1;
145
+ const min = values.length ? Math.min(...values, 0) : 0;
146
+ const range = max - min || 1;
147
+ const plotW = VIEW_W - PAD.left - PAD.right;
148
+ const plotH = VIEW_H - PAD.top - PAD.bottom;
149
+ const xAt = (index) => PAD.left + (axis.length <= 1 ? plotW / 2 : (index / (axis.length - 1)) * plotW);
150
+ const yAt = (value) => PAD.top + plotH - ((value - min) / range) * plotH;
151
+ /** Ticks at the extremes and the midpoint - three labels is all a 140px-tall plot can carry. */
152
+ const yTicks = [min, min + range / 2, max];
153
+ // First, middle and last x label only, for the same reason.
154
+ const xTicks = axis.length <= 3 ? axis.map((_, i) => i) : [0, Math.floor((axis.length - 1) / 2), axis.length - 1];
155
+ return ((0, jsx_runtime_1.jsxs)(Panel, { title: title, children: [(0, jsx_runtime_1.jsxs)("div", { className: "relative", children: [hovered !== null && (
156
+ // Positioned as a percentage of the plot so it tracks the mark at any
157
+ // rendered width - the SVG scales, and this has to scale with it.
158
+ (0, jsx_runtime_1.jsxs)("div", { className: "pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-border bg-overlay px-2 py-1 text-[10px] shadow-lg", style: { left: `${(xAt(hovered) / VIEW_W) * 100}%` }, children: [(0, jsx_runtime_1.jsx)("p", { className: "font-medium", children: axis[hovered] }), series.map((entry, index) => {
159
+ const value = entry.points[hovered]?.y;
160
+ return ((0, jsx_runtime_1.jsxs)("p", { className: "flex items-center gap-1 text-muted", children: [(0, jsx_runtime_1.jsx)("span", { className: "size-1.5 shrink-0 rounded-full", style: { backgroundColor: SERIES_COLORS[index % SERIES_COLORS.length] }, "aria-hidden": true }), (0, jsx_runtime_1.jsx)("span", { className: "tabular-nums", children: value === null || value === undefined ? ai_format_js_1.AI_EMPTY_VALUE : format(value) })] }, entry.name));
161
+ })] })), (0, jsx_runtime_1.jsxs)("svg", { viewBox: `0 0 ${VIEW_W} ${VIEW_H}`, className: "w-full", role: "img", "aria-label": title ?? "Chart", onMouseLeave: () => setHovered(null), children: [yTicks.map((tick) => ((0, jsx_runtime_1.jsxs)("g", { children: [(0, jsx_runtime_1.jsx)("line", { x1: PAD.left, x2: VIEW_W - PAD.right, y1: yAt(tick), y2: yAt(tick), stroke: "var(--separator)", strokeDasharray: "3 3" }), (0, jsx_runtime_1.jsx)("text", { x: PAD.left - 4, y: yAt(tick) + 3, textAnchor: "end", fontSize: "7", fill: "var(--muted)", children: format(tick) })] }, tick))), xTicks.map((index) => ((0, jsx_runtime_1.jsx)("text", { x: xAt(index), y: VIEW_H - 6, textAnchor: "middle", fontSize: "7", fill: "var(--muted)", children: axis[index] }, index))), series.map((entry, seriesIndex) => {
162
+ const color = SERIES_COLORS[seriesIndex % SERIES_COLORS.length];
163
+ if (kind === "bar") {
164
+ const slot = plotW / Math.max(axis.length, 1);
165
+ // A 2px surface gap between adjacent series, so two bars in one
166
+ // slot read as two marks rather than one two-toned block.
167
+ const width = Math.max(2, (slot * 0.7) / series.length - (series.length > 1 ? 2 : 0));
168
+ return entry.points.map((point, index) => point.y === null ? null : ((0, jsx_runtime_1.jsx)("rect", { x: xAt(index) - (width * series.length) / 2 + seriesIndex * width, y: Math.min(yAt(point.y), yAt(0)), width: width, height: Math.max(1, Math.abs(yAt(point.y) - yAt(0))), fill: color, rx: "3" }, `${entry.name}-${point.x}`)));
169
+ }
170
+ // `null` breaks the path into separate segments rather than drawing
171
+ // a straight line through a gap that has no data in it.
172
+ const segments = [];
173
+ let current = [];
174
+ entry.points.forEach((point, index) => {
175
+ if (point.y === null) {
176
+ if (current.length)
177
+ segments.push(current.join(" "));
178
+ current = [];
179
+ return;
180
+ }
181
+ current.push(`${current.length ? "L" : "M"}${xAt(index)},${yAt(point.y)}`);
182
+ });
183
+ if (current.length)
184
+ segments.push(current.join(" "));
185
+ return ((0, jsx_runtime_1.jsxs)("g", { children: [kind === "area" && segments.length === 1 && ((0, jsx_runtime_1.jsx)("path", { d: `${segments[0]} L${xAt(entry.points.length - 1)},${yAt(min)} L${xAt(0)},${yAt(min)} Z`, fill: color, fillOpacity: "0.15" })), segments.map((segment) => ((0, jsx_runtime_1.jsx)("path", { d: segment, fill: "none", stroke: color, strokeWidth: "1.5", strokeLinejoin: "round" }, segment)))] }, entry.name));
186
+ }), axis.map((label, index) => ((0, jsx_runtime_1.jsx)("rect", { x: xAt(index) - plotW / Math.max(axis.length, 1) / 2, y: PAD.top, width: plotW / Math.max(axis.length, 1), height: plotH, fill: "transparent", onMouseEnter: () => setHovered(index) }, label))), hovered !== null && ((0, jsx_runtime_1.jsx)("line", { x1: xAt(hovered), x2: xAt(hovered), y1: PAD.top, y2: PAD.top + plotH, stroke: "var(--border)", strokeWidth: "1" }))] })] }), (series.length > 1 || xLabel || yLabel) && ((0, jsx_runtime_1.jsxs)("div", { className: "mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-muted", children: [series.length > 1 &&
187
+ series.map((entry, index) => ((0, jsx_runtime_1.jsxs)("span", { className: "inline-flex items-center gap-1", children: [(0, jsx_runtime_1.jsx)("span", { className: "size-2 rounded-full", style: { backgroundColor: SERIES_COLORS[index % SERIES_COLORS.length] }, "aria-hidden": true }), entry.name] }, entry.name))), yLabel && (0, jsx_runtime_1.jsxs)("span", { children: ["y: ", yLabel] }), xLabel && (0, jsx_runtime_1.jsxs)("span", { children: ["x: ", xLabel] })] })), caption && (0, jsx_runtime_1.jsx)("p", { className: "mt-2 text-[11px] text-muted", children: caption })] }));
188
+ }
189
+ /* ----------------------------------------------------------------- panels */
190
+ /**
191
+ * `showMetrics`. A delta is coloured by `deltaMeaning`, not by its sign -
192
+ * churn rising is bad and revenue rising is good, and painting both green
193
+ * because the arrow points up is exactly the confidently-wrong signal a
194
+ * dashboard must never send. The arrow still shows direction, so meaning and
195
+ * direction stay two separate channels.
196
+ */
197
+ function AiMetrics({ title, metrics }) {
198
+ return ((0, jsx_runtime_1.jsx)(Panel, { title: title, children: (0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-2 gap-2", children: metrics.map((metric) => {
199
+ const delta = metric.delta ?? null;
200
+ const rising = delta !== null && delta > 0;
201
+ const Arrow = delta === null || delta === 0 ? lucide_react_1.Minus : rising ? lucide_react_1.TrendingUp : lucide_react_1.TrendingDown;
202
+ const good = metric.deltaMeaning === "neutral" || delta === null || delta === 0
203
+ ? null
204
+ : metric.deltaMeaning === "up-is-good"
205
+ ? rising
206
+ : !rising;
207
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "rounded-md bg-surface-secondary p-2.5", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-[11px] text-muted", children: metric.label }), (0, jsx_runtime_1.jsx)("p", { className: "mt-0.5 text-base font-semibold tabular-nums", children: (0, ai_format_js_1.formatAiValue)(metric.value, metric.format) }), delta !== null && ((0, jsx_runtime_1.jsxs)("p", { className: `mt-0.5 flex items-center gap-0.5 text-[11px] tabular-nums ${good === null ? "text-muted" : good ? "text-success" : "text-danger"}`, children: [(0, jsx_runtime_1.jsx)(Arrow, { className: "size-3", "aria-hidden": true }), (0, ai_format_js_1.formatAiValue)(Math.abs(delta), "percent")] })), metric.hint && (0, jsx_runtime_1.jsx)("p", { className: "mt-0.5 text-[10px] text-muted", children: metric.hint })] }, metric.label));
208
+ }) }) }));
209
+ }
210
+ /** `showTimeline`: dated events, oldest first, on a single rail. */
211
+ function AiTimeline({ title, events }) {
212
+ return ((0, jsx_runtime_1.jsx)(Panel, { title: title, children: (0, jsx_runtime_1.jsxs)("ol", { className: "relative space-y-3 ps-4", children: [(0, jsx_runtime_1.jsx)("span", { className: "absolute inset-y-1 start-1 w-px bg-border", "aria-hidden": true }), events.map((event, index) => ((0, jsx_runtime_1.jsxs)("li", { className: "relative", children: [(0, jsx_runtime_1.jsx)("span", { className: `absolute -start-4 top-1 size-2 rounded-full ring-2 ring-surface ${ai_format_js_1.AI_TONE_DOT[event.tone]}`, "aria-hidden": true }), (0, jsx_runtime_1.jsx)("p", { className: "text-[11px] text-muted", children: (0, ai_format_js_1.formatAiValue)(event.at, "datetime") }), (0, jsx_runtime_1.jsx)("p", { className: "text-xs font-medium", children: event.title }), event.detail && (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", children: event.detail })] }, `${event.at}-${index}`)))] }) }));
213
+ }
214
+ /** `showDetails`: one entity's fields, as a definition list rather than a one-row table. */
215
+ function AiDetails({ title, fields }) {
216
+ return ((0, jsx_runtime_1.jsx)(Panel, { title: title, children: (0, jsx_runtime_1.jsx)("dl", { className: "divide-y divide-border/60", children: fields.map((field) => ((0, jsx_runtime_1.jsxs)("div", { className: "flex gap-3 py-1.5 first:pt-0 last:pb-0", children: [(0, jsx_runtime_1.jsx)("dt", { className: "w-2/5 shrink-0 text-xs text-muted", children: field.label }), (0, jsx_runtime_1.jsx)("dd", { className: `min-w-0 flex-1 text-xs break-words ${TONE_TEXT[field.tone]}`, children: field.format === "status" && field.value !== null ? ((0, jsx_runtime_1.jsx)(StatusChip, { value: field.value })) : ((0, ai_format_js_1.formatAiValue)(field.value, field.format)) })] }, field.label))) }) }));
217
+ }
218
+ /** `showSteps`: an ordered procedure. `renderDetail` is how the panel feeds each step back through its markdown renderer. */
219
+ function AiSteps({ title, steps, renderDetail, }) {
220
+ return ((0, jsx_runtime_1.jsx)(Panel, { title: title, children: (0, jsx_runtime_1.jsx)("ol", { className: "space-y-2.5", children: steps.map((step, index) => ((0, jsx_runtime_1.jsxs)("li", { className: "flex gap-2.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-accent/12 text-[10px] font-semibold text-accent", children: index + 1 }), (0, jsx_runtime_1.jsxs)("div", { className: "min-w-0 flex-1", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-xs font-medium", children: step.title }), step.detail && ((0, jsx_runtime_1.jsx)("div", { className: "mt-0.5 text-xs text-muted", children: renderDetail ? renderDetail(step.detail) : (0, jsx_runtime_1.jsx)("span", { className: "whitespace-pre-wrap", children: step.detail }) }))] })] }, step.title))) }) }));
221
+ }
222
+ /**
223
+ * `showSources`: what a grounded answer actually rested on. The point of a
224
+ * citation is that it can be opened, so a source with a `uri` is a link and one
225
+ * without is still a named row - never a marker with nothing behind it.
226
+ */
227
+ function AiSources({ sources }) {
228
+ return ((0, jsx_runtime_1.jsxs)("figure", { className: "my-3 rounded-lg border border-border bg-surface-secondary p-2.5", children: [(0, jsx_runtime_1.jsx)("figcaption", { className: "mb-1.5 text-[11px] font-semibold tracking-wide text-muted uppercase", children: "Sources" }), (0, jsx_runtime_1.jsx)("ul", { className: "space-y-1.5", children: sources.map((source, index) => {
229
+ const label = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(lucide_react_1.FileText, { className: "mt-0.5 size-3 shrink-0 text-muted", "aria-hidden": true }), (0, jsx_runtime_1.jsxs)("span", { className: "min-w-0 flex-1", children: [(0, jsx_runtime_1.jsx)("span", { className: "block truncate text-xs font-medium", children: source.title }), source.snippet && (0, jsx_runtime_1.jsxs)("span", { className: "mt-0.5 block text-[11px] text-muted italic", children: ["\u201C", source.snippet, "\u201D"] })] })] }));
230
+ return ((0, jsx_runtime_1.jsx)("li", { children: source.uri ? ((0, jsx_runtime_1.jsxs)("a", { href: source.uri, target: "_blank", rel: "noopener noreferrer", className: "flex gap-1.5 rounded-sm hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus", children: [label, (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "mt-0.5 size-3 shrink-0 text-muted", "aria-hidden": true })] })) : ((0, jsx_runtime_1.jsx)("span", { className: "flex gap-1.5", children: label })) }, `${source.uri ?? source.title}-${index}`));
231
+ }) })] }));
232
+ }