@cosmoops/connect-sdk 0.41.0 → 0.42.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 (46) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/dist/cjs/client/components/access-gate.js +18 -40
  3. package/dist/cjs/client/components/apps-grid.js +11 -3
  4. package/dist/cjs/client/components/ask-ai-errors.js +109 -0
  5. package/dist/cjs/client/components/ask-ai-turns.js +12 -0
  6. package/dist/cjs/client/components/ask-ai.js +75 -8
  7. package/dist/cjs/client/components/plans.js +8 -2
  8. package/dist/cjs/client/components/readiness.js +2 -1
  9. package/dist/cjs/client/components/subscription.js +4 -4
  10. package/dist/cjs/client/components/user-menu.js +16 -11
  11. package/dist/cjs/client/handoff.js +78 -0
  12. package/dist/cjs/client/index.js +9 -6
  13. package/dist/cjs/client/provider.js +75 -4
  14. package/dist/cjs/client/use-checkout.js +23 -16
  15. package/dist/cjs/server/client.js +5 -1
  16. package/dist/cjs/server/handler.js +4 -1
  17. package/dist/cjs/server/resources/identity.js +12 -0
  18. package/dist/cjs/server/resources/whatsapp.js +58 -0
  19. package/dist/esm/client/components/access-gate.js +18 -40
  20. package/dist/esm/client/components/apps-grid.js +11 -3
  21. package/dist/esm/client/components/ask-ai-errors.js +102 -0
  22. package/dist/esm/client/components/ask-ai-turns.js +12 -0
  23. package/dist/esm/client/components/ask-ai.js +76 -9
  24. package/dist/esm/client/components/plans.js +8 -2
  25. package/dist/esm/client/components/readiness.js +2 -1
  26. package/dist/esm/client/components/subscription.js +4 -4
  27. package/dist/esm/client/components/user-menu.js +16 -11
  28. package/dist/esm/client/handoff.js +72 -0
  29. package/dist/esm/client/index.js +4 -3
  30. package/dist/esm/client/provider.js +75 -4
  31. package/dist/esm/client/use-checkout.js +23 -16
  32. package/dist/esm/server/client.js +5 -1
  33. package/dist/esm/server/handler.js +4 -1
  34. package/dist/esm/server/resources/identity.js +12 -0
  35. package/dist/esm/server/resources/whatsapp.js +54 -0
  36. package/dist/types/client/components/ask-ai-errors.d.ts +41 -0
  37. package/dist/types/client/components/ask-ai-turns.d.ts +5 -0
  38. package/dist/types/client/handoff.d.ts +62 -0
  39. package/dist/types/client/index.d.ts +3 -1
  40. package/dist/types/client/provider.d.ts +72 -5
  41. package/dist/types/client/use-checkout.d.ts +4 -1
  42. package/dist/types/server/client.d.ts +4 -1
  43. package/dist/types/server/resources/identity.d.ts +10 -0
  44. package/dist/types/server/resources/whatsapp.d.ts +44 -0
  45. package/dist/types/types.d.ts +42 -0
  46. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,81 @@
3
3
  `@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
4
4
  versions may carry breaking changes** and patch versions never do.
5
5
 
6
+ ## 0.42.0
7
+
8
+ ### Ask AI explains failures instead of printing them
9
+
10
+ Nothing to wire - update and the panel behaves better on a bad day.
11
+
12
+ - **A tool step that fails says so where its result would have been.** When
13
+ Connect cannot reach your application's MCP server - it is down, deploying,
14
+ or timing out - the step renders a short inline notice such as
15
+ *"CosmoOps CRM can't be reached right now, so nothing was looked up. Try
16
+ again in a few minutes."*, and the assistant answers in the same plain
17
+ words. It used to answer `TypeError: fetch failed`. Your own MCP server's
18
+ refusals (`isError: true` with a text block) are shown the same way, so
19
+ write them for your user. Reopened conversations in History show them too.
20
+ - **Request failures are worded by cause**, never shown raw: offline, signed
21
+ out, no access, throttled, or Connect having trouble. The raw error still
22
+ goes to the browser console for whoever is debugging.
23
+ - **"Try again"** re-asks the failed question in place of the failed exchange.
24
+ It is withheld where a retry cannot help - signed out, no access, or Ask AI
25
+ switched off for the workspace.
26
+
27
+ ### `streamResource` throws `ConnectError`
28
+
29
+ A failed `streamResource()` from `useConnect()` now throws a `ConnectError`
30
+ carrying `status` and `code`, rather than a bare `Error`. The message is
31
+ unchanged and `ConnectError` extends `Error`, so existing `catch` blocks keep
32
+ working; branch on `error.status` instead of the message if you do.
33
+
34
+ ### Every trip to Connect comes back: `openConnect` and `handoff`
35
+
36
+ Each Connect button in this package - the user menu's Profile and Billing,
37
+ `ConnectManageSubscriptionButton`, `ConnectInvoicesButton`,
38
+ `ConnectProfileButton`, "Manage on Connect", checkout, a readiness blocker's
39
+ fix, the access gate - now ends back in your app when the task is done. Most
40
+ of them used to open a new tab that went nowhere: the customer finished on
41
+ Connect, stayed there, and your tab still showed the old state.
42
+
43
+ ```tsx
44
+ <ConnectProvider handoff="popup"> {/* the default */}
45
+ ```
46
+
47
+ - **`"popup"`** (default) opens Connect in a new tab that **closes itself**
48
+ when the task is done and tells your page first. Every Connect component on
49
+ the page then re-reads, so a new plan or a fixed payment shows up without a
50
+ reload. A blocked popup falls back to `"redirect"`.
51
+ - **`"redirect"`** sends this tab to Connect and back. Prefer it for an
52
+ installed PWA or an app inside an iframe.
53
+
54
+ **Register your app's origin** among your client's redirect URIs in Connect.
55
+ Connect only sends somebody back to an origin registered for your client; an
56
+ unregistered one still reaches Connect, it just gets no way back.
57
+
58
+ For your own buttons, `useConnect().openConnect(path)` does the same thing -
59
+ `openConnect("/calendar/settings")`, or a readiness blocker's `resolveUrl`.
60
+ Call it from a press handler. `onHandoffResult` on the provider tells you how
61
+ each trip ended (`completed`, `cancelled`, `closed`); a redirect return
62
+ arrives as `?connect_result=`, which the provider reads and strips.
63
+
64
+ Your bridge now reports your public `clientId` on `/session`, so you do not
65
+ need to pass `clientId` to `<ConnectProvider>` for any of this, and
66
+ `useConnect().clientId` is filled in from it.
67
+
68
+ Behaviour changes worth knowing:
69
+
70
+ - **`<ConnectAccessGate>`'s fix button follows `handoff`.** It used to
71
+ navigate this tab with `?next=`, which only Connect's subscribe page
72
+ honoured - a lapsed payment, a missing payment method or verification left
73
+ the customer on Connect. It now comes back from every blocker.
74
+ - **`useConnectCheckout` no longer reports `blocked`.** A blocked window
75
+ falls back to a same-tab trip instead of asking the customer to click a
76
+ link. The value stays in `ConnectCheckoutStatus` so code handling it still
77
+ compiles.
78
+ - `ConnectAppsGrid` still opens a *subscribed* application in a plain new
79
+ tab - that is leaving for another product, not a task on Connect.
80
+
6
81
  ## 0.41.0
7
82
 
8
83
  ### Calling from the browser: `<ConnectCallButton>`
@@ -275,26 +275,26 @@ function AccessGateFallback({ state, blocker, className, }) {
275
275
  * to a choice they can act on. Sending them to Connect's own subscribe page
276
276
  * always resolves.
277
277
  *
278
- * ### Same tab, and it carries the way back
278
+ * ### A hand-off, so it ends back here
279
279
  *
280
- * It used to `window.open(resolveUrl, "_blank")`. A new tab is the wrong
281
- * shape for a step you have to finish before you can continue: the customer
282
- * subscribes over there, is left on Connect's billing page with nothing
283
- * pointing home, and the tab they started in is still showing this card. The
284
- * most common ending was two open tabs and the belief that the payment had
285
- * not worked.
280
+ * It used to `window.open(resolveUrl, "_blank")`, and the customer was left on
281
+ * Connect's billing page with nothing pointing home while the tab they
282
+ * started in still showed this card - two open tabs and the belief that the
283
+ * payment had not worked. Then it went same-tab with `?next=`, which only
284
+ * Connect's subscribe page honoured, so every other blocker (a lapsed
285
+ * payment, a missing payment method, verification) still stranded them.
286
286
  *
287
- * So: a same-tab navigation carrying `?next=` back to the page they are
288
- * standing on, which Connect honours after a subscribe once the origin
289
- * matches one registered for this application's client
290
- * (`resolveApplicationReturnUrl`). An unregistered origin costs the return
291
- * trip and nothing else - they still reach Connect, still resolve the
292
- * blocker, and `ConnectAccessGate`'s focus re-check catches them on the way
293
- * back. Partners should register their app's origin; the fallback is a
294
- * degraded path, not a broken one.
287
+ * Now it is `openConnect`, like every other Connect button: Connect brings
288
+ * them back from whichever page the blocker points at, closing itself in
289
+ * `popup` mode or navigating back in `redirect` mode, and the provider
290
+ * re-reads the entitlements the moment it reports in - so this card is
291
+ * replaced by the app without anyone reloading. The focus re-check in
292
+ * `ConnectAccessGate` stays as the backstop for an origin Connect could not
293
+ * validate, which still reaches the blocker and just gets no way back.
295
294
  */
296
295
  function GateCard({ icon: Icon, iconClassName, title, message, actionLabel, resolveUrl, className, }) {
297
- return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, children: (0, jsx_runtime_1.jsxs)(react_2.Card, { className: className, children: [(0, jsx_runtime_1.jsxs)(react_2.Card.Header, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(Icon, { className: `size-5 shrink-0 ${iconClassName}`, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)(react_2.Card.Title, { children: title })] }), (0, jsx_runtime_1.jsx)(react_2.Card.Description, { children: message })] }), resolveUrl && ((0, jsx_runtime_1.jsx)(react_2.Card.Footer, { children: (0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "primary", onPress: () => window.location.assign(withReturnTo(resolveUrl)), children: [actionLabel ?? "Resolve on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }) }))] }) }) }));
296
+ const { openConnect } = (0, provider_js_1.useConnect)();
297
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, children: (0, jsx_runtime_1.jsxs)(react_2.Card, { className: className, children: [(0, jsx_runtime_1.jsxs)(react_2.Card.Header, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(Icon, { className: `size-5 shrink-0 ${iconClassName}`, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)(react_2.Card.Title, { children: title })] }), (0, jsx_runtime_1.jsx)(react_2.Card.Description, { children: message })] }), resolveUrl && ((0, jsx_runtime_1.jsx)(react_2.Card.Footer, { children: (0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "primary", onPress: () => openConnect(resolveUrl), children: [actionLabel ?? "Resolve on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }) }))] }) }) }));
298
298
  }
299
299
  /**
300
300
  * The skippable payment-due warning. Dismissal is scoped to this specific
@@ -303,36 +303,14 @@ function GateCard({ icon: Icon, iconClassName, title, message, actionLabel, reso
303
303
  * though an old dismissal is still on record.
304
304
  */
305
305
  function PaymentRetryBanner({ blocker, className, }) {
306
+ const { openConnect } = (0, provider_js_1.useConnect)();
306
307
  const dismissKey = blocker.graceEndsAt ? `connect_dismissed_payment_retry_pending_${blocker.graceEndsAt}` : null;
307
308
  const [dismissed, setDismissed] = (0, react_1.useState)(() => dismissKey !== null && typeof window !== "undefined" && window.localStorage.getItem(dismissKey) === "1");
308
309
  if (dismissed)
309
310
  return null;
310
- return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)(motion_js_1.m.div, { className: (0, cn_js_1.cn)("flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", className), role: "status", initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" }), blocker.message, blocker.graceEndsAt && ` Resolve by ${formatDate(blocker.graceEndsAt)} to avoid losing access.`] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [blocker.resolveUrl && ((0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "secondary", onPress: () => window.open(blocker.resolveUrl, "_blank", "noopener,noreferrer"), children: ["Resolve payment", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })), (0, jsx_runtime_1.jsx)(react_2.Button, { variant: "tertiary", isIconOnly: true, "aria-label": "Dismiss", onPress: () => {
311
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)(motion_js_1.m.div, { className: (0, cn_js_1.cn)("flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", className), role: "status", initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" }), blocker.message, blocker.graceEndsAt && ` Resolve by ${formatDate(blocker.graceEndsAt)} to avoid losing access.`] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [blocker.resolveUrl && ((0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "secondary", onPress: () => openConnect(blocker.resolveUrl), children: ["Resolve payment", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })), (0, jsx_runtime_1.jsx)(react_2.Button, { variant: "tertiary", isIconOnly: true, "aria-label": "Dismiss", onPress: () => {
311
312
  setDismissed(true);
312
313
  if (dismissKey && typeof window !== "undefined")
313
314
  window.localStorage.setItem(dismissKey, "1");
314
315
  }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] })] }) }));
315
316
  }
316
- /**
317
- * `resolveUrl` with `?next=` pointing at the page the customer is standing
318
- * on, so Connect can hand them back once the blocker is gone.
319
- *
320
- * Returns the URL untouched if anything is off - no `window`, an unparseable
321
- * `resolveUrl`, a `next` already set by the server. Getting back is a
322
- * courtesy on top of resolving the blocker, and it must never be the reason
323
- * somebody cannot reach the screen that resolves it.
324
- */
325
- function withReturnTo(resolveUrl) {
326
- if (typeof window === "undefined")
327
- return resolveUrl;
328
- try {
329
- const url = new URL(resolveUrl);
330
- if (url.searchParams.has("next"))
331
- return resolveUrl;
332
- url.searchParams.set("next", window.location.href);
333
- return url.toString();
334
- }
335
- catch {
336
- return resolveUrl;
337
- }
338
- }
@@ -105,7 +105,7 @@ function GridSkeleton() {
105
105
  * button that opens an empty box.
106
106
  */
107
107
  function ConnectAppsGrid({ className, label = "Switch app", variant = "row" }) {
108
- const { session, isLoading: sessionLoading, appUrl } = (0, provider_js_1.useConnect)();
108
+ const { session, isLoading: sessionLoading, openConnect } = (0, provider_js_1.useConnect)();
109
109
  const { data, isLoading, error } = (0, provider_js_1.useConnectApps)();
110
110
  const [query, setQuery] = (0, react_2.useState)("");
111
111
  const apps = (0, react_2.useMemo)(() => data?.apps ?? [], [data]);
@@ -126,8 +126,16 @@ function ConnectAppsGrid({ className, label = "Switch app", variant = "row" }) {
126
126
  // application's page on Connect, which is where a plan is actually
127
127
  // chosen. The SDK owns this mapping so no integration has to know either
128
128
  // URL shape.
129
- const target = app.subscribed && app.launchUrl ? app.launchUrl : `${appUrl}/apps/${app.applicationId}`;
130
- window.open(target, "_blank", "noopener,noreferrer");
129
+ //
130
+ // The two are different kinds of trip. Launching another application is
131
+ // leaving for somewhere else, so it is a plain new tab with no way back
132
+ // and no `opener`. Choosing a plan on Connect is a task, so it is a
133
+ // hand-off that ends back here.
134
+ if (app.subscribed && app.launchUrl) {
135
+ window.open(app.launchUrl, "_blank", "noopener,noreferrer");
136
+ return;
137
+ }
138
+ openConnect(`/apps/${encodeURIComponent(app.applicationId)}`);
131
139
  };
132
140
  const showSearch = apps.length > SEARCH_THRESHOLD;
133
141
  const isEmpty = !isLoading && !error && mine.length === 0 && explore.length === 0;
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AskAiStreamError = exports.TOOL_GENERIC_FAILURE = exports.ASK_AI_GENERIC_FAILURE = void 0;
4
+ exports.describeAskAiFailure = describeAskAiFailure;
5
+ exports.isRetryableAskAiFailure = isRetryableAskAiFailure;
6
+ exports.toolFailureFromOutput = toolFailureFromOutput;
7
+ const errors_js_1 = require("../../errors.js");
8
+ /**
9
+ * What Ask AI shows when something fails - a sentence for a person, never the
10
+ * error itself.
11
+ *
12
+ * The raw text is somebody else's diagnostic: `TypeError: Failed to fetch`
13
+ * from the browser, `Connect bridge returned 502` from the provider, a
14
+ * validation message naming a request field. None of it tells the person what
15
+ * happened or what to do, and the last one tells them something about the
16
+ * API they should not need to know. The real error is still on the console of
17
+ * whoever is debugging; this module decides only what is on screen.
18
+ */
19
+ exports.ASK_AI_GENERIC_FAILURE = "Something went wrong. Please try again.";
20
+ exports.TOOL_GENERIC_FAILURE = "That step couldn't be completed. Try again in a moment.";
21
+ /** Longest partner-written failure shown inline - a sentence or two, not a dump. */
22
+ const MAX_TOOL_FAILURE_CHARS = 280;
23
+ /**
24
+ * A failure Connect reported inside the stream, after it had opened.
25
+ *
26
+ * Kept apart from a transport failure because its text was written for the
27
+ * person by Connect's own stream error handler, so it may be shown as it is.
28
+ */
29
+ class AskAiStreamError extends Error {
30
+ name = "AskAiStreamError";
31
+ }
32
+ exports.AskAiStreamError = AskAiStreamError;
33
+ /** The SDK default and this panel's old fallback - both say nothing. */
34
+ const UNINFORMATIVE = new Set(["", "An error occurred.", "Ask AI failed"]);
35
+ /** The browser's own words for "no response arrived", across engines. */
36
+ function isNetworkFailure(cause) {
37
+ if (!(cause instanceof Error))
38
+ return false;
39
+ return (cause.name === "TypeError" &&
40
+ /failed to fetch|fetch failed|load failed|networkerror|network error|network connection was lost/i.test(cause.message));
41
+ }
42
+ function describeAskAiFailure(cause) {
43
+ if (cause instanceof AskAiStreamError) {
44
+ return UNINFORMATIVE.has(cause.message.trim()) ? exports.ASK_AI_GENERIC_FAILURE : cause.message;
45
+ }
46
+ if (cause instanceof errors_js_1.ConnectError) {
47
+ if (cause.status === 401 || cause.isSessionExpired)
48
+ return "Your session has ended. Sign in again to keep asking.";
49
+ if (cause.status === 403)
50
+ return "You don't have access to Ask AI in this workspace. Ask an admin if you think you should.";
51
+ if (cause.status === 429)
52
+ return "That's a lot of questions at once. Wait a moment, then try again.";
53
+ // The one refusal Connect words for a person: Ask AI is switched off for
54
+ // this workspace, and retrying will not change that.
55
+ if (cause.status === 503 && cause.code === "unavailable" && cause.message)
56
+ return cause.message;
57
+ if (cause.status === 0 || cause.status >= 500)
58
+ return "The assistant is having trouble right now. Try again in a moment.";
59
+ return exports.ASK_AI_GENERIC_FAILURE;
60
+ }
61
+ if (isNetworkFailure(cause))
62
+ return "Couldn't reach the assistant. Check your connection, then try again.";
63
+ return exports.ASK_AI_GENERIC_FAILURE;
64
+ }
65
+ /**
66
+ * Whether trying the same question again could help.
67
+ *
68
+ * Off for the failures a retry cannot fix - signed out, no access, Ask AI
69
+ * switched off - so the panel does not offer a button that will fail the same
70
+ * way every time.
71
+ */
72
+ function isRetryableAskAiFailure(cause) {
73
+ if (cause instanceof errors_js_1.ConnectError) {
74
+ return !(cause.status === 401 || cause.status === 403 || cause.isSessionExpired || (cause.status === 503 && cause.code === "unavailable"));
75
+ }
76
+ return true;
77
+ }
78
+ function truncate(text) {
79
+ const trimmed = text.trim();
80
+ return trimmed.length > MAX_TOOL_FAILURE_CHARS ? `${trimmed.slice(0, MAX_TOOL_FAILURE_CHARS - 1)}…` : trimmed;
81
+ }
82
+ /**
83
+ * The failure a tool's *result* reports, or `null` when it succeeded.
84
+ *
85
+ * An application's tool answers `{ isError: true, ... }` rather than throwing,
86
+ * which is MCP's distinction between "the tool refused" and "the call broke".
87
+ * Connect puts its own sentence in `error.message` when it could not reach
88
+ * the application at all; otherwise the application's first text block is its
89
+ * own refusal ("You don't have permission to ..."), written for its own user.
90
+ */
91
+ function toolFailureFromOutput(output) {
92
+ if (typeof output !== "object" || output === null)
93
+ return null;
94
+ const result = output;
95
+ if (result.isError !== true)
96
+ return null;
97
+ if (typeof result.error?.message === "string" && result.error.message.trim())
98
+ return truncate(result.error.message);
99
+ if (Array.isArray(result.content)) {
100
+ for (const part of result.content) {
101
+ if (part && typeof part === "object" && part.type === "text") {
102
+ const text = part.text;
103
+ if (typeof text === "string" && text.trim())
104
+ return truncate(text);
105
+ }
106
+ }
107
+ }
108
+ return exports.TOOL_GENERIC_FAILURE;
109
+ }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.lastTextBlockIndex = lastTextBlockIndex;
4
4
  exports.appendText = appendText;
5
5
  exports.turnsFromStored = turnsFromStored;
6
+ const ask_ai_errors_js_1 = require("./ask-ai-errors.js");
6
7
  /**
7
8
  * Index of the final text block, or `-1`. A reverse loop rather than
8
9
  * `findLastIndex`, which is ES2023 - this package targets ES2022 so its
@@ -78,6 +79,7 @@ function turnsFromStored(turns) {
78
79
  id: typeof part.toolCallId === "string" ? part.toolCallId : `${turn.id}-${blocks.length}`,
79
80
  name: part.type.slice("tool-".length),
80
81
  input: part.input,
82
+ ...storedFailure(part),
81
83
  },
82
84
  ];
83
85
  }
@@ -85,3 +87,13 @@ function turnsFromStored(turns) {
85
87
  return { id: turn.id, role: turn.role, blocks, reasoning };
86
88
  });
87
89
  }
90
+ /**
91
+ * A stored step's failure, worded exactly as it was live. A thrown error is
92
+ * stored with its raw `errorText`, which is never shown - only the fact.
93
+ */
94
+ function storedFailure(part) {
95
+ if (part.state === "output-error")
96
+ return { failure: ask_ai_errors_js_1.TOOL_GENERIC_FAILURE };
97
+ const failure = (0, ask_ai_errors_js_1.toolFailureFromOutput)(part.output);
98
+ return failure ? { failure } : {};
99
+ }
@@ -68,6 +68,7 @@ const ai_screens_js_1 = require("./ai-screens.js");
68
68
  const ai_widgets_js_1 = require("./ai-widgets.js");
69
69
  const ask_ai_history_js_1 = require("./ask-ai-history.js");
70
70
  const ask_ai_turns_js_1 = require("./ask-ai-turns.js");
71
+ const ask_ai_errors_js_1 = require("./ask-ai-errors.js");
71
72
  /**
72
73
  * The panel's open width. The *inner* pane always carries it, so the content
73
74
  * keeps its layout while the outer shell's width animates to zero around it.
@@ -151,6 +152,16 @@ function renderTool(name, input, renderMarkdown, screens, renderLink) {
151
152
  return null;
152
153
  }
153
154
  }
155
+ /**
156
+ * A step that failed, drawn where its result would have been.
157
+ *
158
+ * Small and inline rather than a banner: the turn itself has not failed - the
159
+ * model goes on to say what happened - and one unreachable application should
160
+ * read as one missing piece of an answer, not as the panel breaking.
161
+ */
162
+ function ToolFailure({ message }) {
163
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "my-2 flex items-start gap-2 rounded-md border border-warning/30 bg-warning/5 px-2.5 py-2 text-xs text-foreground", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.TriangleAlert, { className: "mt-px size-3.5 shrink-0 text-warning", "aria-hidden": true }), (0, jsx_runtime_1.jsx)("p", { children: message })] }));
164
+ }
154
165
  /** Placeholder while a tool's arguments are still arriving, sized like the real thing. */
155
166
  function ToolSkeleton() {
156
167
  return ((0, jsx_runtime_1.jsx)("div", { className: "my-3 flex h-24 animate-pulse items-center justify-center rounded-lg border border-border bg-surface-secondary", role: "status", "aria-label": "Preparing result", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-4 text-accent/60", "aria-hidden": true }) }));
@@ -175,7 +186,7 @@ function ToolSkeleton() {
175
186
  async function readStream(response, onChunk) {
176
187
  const body = response.body;
177
188
  if (!body)
178
- throw new Error("Ask AI returned no stream");
189
+ throw new ask_ai_errors_js_1.AskAiStreamError("");
179
190
  const reader = body.pipeThrough(new TextDecoderStream()).getReader();
180
191
  let buffer = "";
181
192
  for (;;) {
@@ -221,8 +232,23 @@ async function readStream(response, onChunk) {
221
232
  if (chunk.toolCallId)
222
233
  onChunk({ toolInput: { id: chunk.toolCallId, input: chunk.input } });
223
234
  break;
235
+ case "tool-output-available": {
236
+ // A tool that answered `isError` - an application that could not be
237
+ // reached, or one that refused. Successful output is not drawn: the
238
+ // model's prose is the reading of it.
239
+ const failure = (0, ask_ai_errors_js_1.toolFailureFromOutput)(chunk.output);
240
+ if (chunk.toolCallId && failure)
241
+ onChunk({ toolFailure: { id: chunk.toolCallId, message: failure } });
242
+ break;
243
+ }
244
+ case "tool-output-error":
245
+ // `errorText` here is a thrown error's raw message - the fact of the
246
+ // failure is shown, its wording never is.
247
+ if (chunk.toolCallId)
248
+ onChunk({ toolFailure: { id: chunk.toolCallId, message: ask_ai_errors_js_1.TOOL_GENERIC_FAILURE } });
249
+ break;
224
250
  case "error":
225
- throw new Error(chunk.errorText || "Ask AI failed");
251
+ throw new ask_ai_errors_js_1.AskAiStreamError(chunk.errorText ?? "");
226
252
  }
227
253
  }
228
254
  }
@@ -274,6 +300,12 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
274
300
  const [turns, setTurns] = (0, react_1.useState)([]);
275
301
  const [input, setInput] = (0, react_1.useState)("");
276
302
  const [isBusy, setIsBusy] = (0, react_1.useState)(false);
303
+ /**
304
+ * The last request's failure, worded for a person - and the question to ask
305
+ * again, when asking again could help. `retry` is `null` for a failure no
306
+ * retry fixes (signed out, no access, Ask AI switched off), so the panel
307
+ * never offers a button that fails the same way every time.
308
+ */
277
309
  const [error, setError] = (0, react_1.useState)(null);
278
310
  const [showHistory, setShowHistory] = (0, react_1.useState)(false);
279
311
  /**
@@ -332,7 +364,13 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
332
364
  abortRef.current = null;
333
365
  setIsBusy(false);
334
366
  }, []);
335
- const send = (0, react_1.useCallback)(async (text) => {
367
+ const send = (0, react_1.useCallback)(
368
+ /**
369
+ * `prior` is the conversation the question is asked after - the current
370
+ * one, except on a retry, which drops the failed exchange first so the
371
+ * question is not shown (or sent) twice.
372
+ */
373
+ async (text, prior = turns) => {
336
374
  const controller = new AbortController();
337
375
  abortRef.current = controller;
338
376
  setError(null);
@@ -348,7 +386,7 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
348
386
  // strings. Built from the turns *plus* this one rather than from state,
349
387
  // which has not been committed yet at this point. Only text is sent back:
350
388
  // a widget is a rendering of something the model already said.
351
- const history = [...turns, userTurn].map((turn) => ({
389
+ const history = [...prior, userTurn].map((turn) => ({
352
390
  id: turn.id,
353
391
  role: turn.role,
354
392
  parts: [
@@ -361,7 +399,7 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
361
399
  },
362
400
  ],
363
401
  }));
364
- setTurns((current) => [...current, userTurn]);
402
+ setTurns([...prior, userTurn]);
365
403
  const patchAnswer = (patch) => setTurns((current) => current.map((turn) => (turn.id === answerId ? patch(turn) : turn)));
366
404
  try {
367
405
  // Relative to the provider's own `basePath` - `streamResource` prefixes
@@ -395,6 +433,13 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
395
433
  const { id, name } = chunk.toolStart;
396
434
  patchAnswer((turn) => ({ ...turn, blocks: [...turn.blocks, { kind: "tool", id, name, input: null }] }));
397
435
  }
436
+ if (chunk.toolFailure) {
437
+ const { id, message } = chunk.toolFailure;
438
+ patchAnswer((turn) => ({
439
+ ...turn,
440
+ blocks: turn.blocks.map((block) => block.kind === "tool" && block.id === id ? { ...block, failure: message } : block),
441
+ }));
442
+ }
398
443
  if (chunk.toolInput) {
399
444
  const { id, input: toolInput } = chunk.toolInput;
400
445
  patchAnswer((turn) => ({
@@ -408,7 +453,10 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
408
453
  // Pressing Stop aborts the fetch, which lands here - a decision, not a
409
454
  // failure, and nothing to report.
410
455
  if (!controller.signal.aborted) {
411
- setError(cause instanceof Error ? cause.message : "Ask AI failed");
456
+ // The real error is for whoever is debugging; the panel shows a
457
+ // sentence. See `describeAskAiFailure`.
458
+ console.error("[connect] Ask AI request failed:", cause);
459
+ setError({ message: (0, ask_ai_errors_js_1.describeAskAiFailure)(cause), retry: (0, ask_ai_errors_js_1.isRetryableAskAiFailure)(cause) ? text : null });
412
460
  }
413
461
  }
414
462
  finally {
@@ -450,6 +498,25 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
450
498
  setConversationId(id);
451
499
  setShowHistory(false);
452
500
  }, []);
501
+ /**
502
+ * Asks the failed question again, in place of the failed exchange.
503
+ *
504
+ * Everything from the last question onwards is dropped - the question and
505
+ * whatever half-answer arrived before the failure - so the retry reads as
506
+ * the same question answered, not as the person asking twice.
507
+ */
508
+ const retry = (0, react_1.useCallback)(() => {
509
+ if (!error?.retry || isBusy)
510
+ return;
511
+ let cut = turns.length;
512
+ for (let index = turns.length - 1; index >= 0; index--) {
513
+ if (turns[index]?.role === "user") {
514
+ cut = index;
515
+ break;
516
+ }
517
+ }
518
+ void send(error.retry, turns.slice(0, cut));
519
+ }, [error, isBusy, turns, send]);
453
520
  const lastTurn = turns.at(-1);
454
521
  const isAnswering = isBusy && lastTurn?.role === "assistant";
455
522
  const rendered = (0, react_1.useMemo)(() => turns.map((turn) => {
@@ -459,7 +526,7 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
459
526
  // The user's own turn is shown as they typed it - running a
460
527
  // question that happens to start with `#` through the markdown
461
528
  // renderer would turn it into a heading.
462
- (0, jsx_runtime_1.jsx)("div", { children: turn.role === "user" ? ((0, jsx_runtime_1.jsx)("p", { className: "whitespace-pre-wrap", children: block.text })) : (renderMarkdown(block.text, isLive && index === lastTextIndex)) }, index)) : block.input === null ? ((0, jsx_runtime_1.jsx)(ToolSkeleton, {}, block.id)) : ((0, jsx_runtime_1.jsx)("div", { children: renderTool(block.name, block.input, renderMarkdown, screens, renderLink) }, block.id))), isLive && lastTextIndex === -1 && turn.blocks.length === 0 && (0, jsx_runtime_1.jsx)(ThinkingShimmer, {})] }, turn.id));
529
+ (0, jsx_runtime_1.jsx)("div", { children: turn.role === "user" ? ((0, jsx_runtime_1.jsx)("p", { className: "whitespace-pre-wrap", children: block.text })) : (renderMarkdown(block.text, isLive && index === lastTextIndex)) }, index)) : block.failure ? ((0, jsx_runtime_1.jsx)(ToolFailure, { message: block.failure }, block.id)) : block.input === null ? ((0, jsx_runtime_1.jsx)(ToolSkeleton, {}, block.id)) : ((0, jsx_runtime_1.jsx)("div", { children: renderTool(block.name, block.input, renderMarkdown, screens, renderLink) }, block.id))), isLive && lastTextIndex === -1 && turn.blocks.length === 0 && (0, jsx_runtime_1.jsx)(ThinkingShimmer, {})] }, turn.id));
463
530
  }), [turns, isAnswering, lastTurn?.id, assistantName, renderMarkdown]);
464
531
  // Signed out there is no workspace to ground an answer in, and the bridge
465
532
  // would answer 401 - so there is nothing to offer, not even a button.
@@ -480,7 +547,7 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
480
547
  // Disabled on an empty one rather than hidden: a control that
481
548
  // appears and disappears as you use the panel is harder to find
482
549
  // than one that is always in the same place.
483
- isDisabled: turns.length === 0, onPress: startNewConversation, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { className: "size-4" }) }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": showHistory ? "Hide past conversations" : "Past conversations", "aria-pressed": showHistory, onPress: () => setShowHistory((open) => !open), children: (0, jsx_runtime_1.jsx)(lucide_react_1.History, { className: "size-4" }) }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] }), showHistory ? ((0, jsx_runtime_1.jsx)("div", { className: "min-h-0 flex-1 overflow-y-auto", children: (0, jsx_runtime_1.jsx)(ask_ai_history_js_1.AskAiHistory, { refreshKey: writeCount, activeConversationId: conversationId, onOpenConversation: openConversation }) })) : ((0, jsx_runtime_1.jsxs)("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "space-y-3", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: hint }), currentScreen?.suggestions?.length ? ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap gap-1.5", children: currentScreen.suggestions.map((suggestion) => ((0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "secondary", className: "h-auto rounded-full px-2.5 py-1 text-start text-xs whitespace-normal", isDisabled: isBusy, onPress: () => void send(suggestion), children: suggestion }, suggestion))) })) : null] })), rendered, isBusy && lastTurn?.role === "user" && ((0, jsx_runtime_1.jsxs)("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [(0, jsx_runtime_1.jsx)("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), (0, jsx_runtime_1.jsx)(ThinkingShimmer, {})] })), error && (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-danger", children: error }), (0, jsx_runtime_1.jsx)("div", { ref: bottomRef })] })), (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", className: "flex-1", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "sr-only", children: placeholder }), (0, jsx_runtime_1.jsx)(react_2.Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Square, { className: "size-3.5" }) })) : ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Send, { className: "size-3.5" }) }))] })] }) })] }));
550
+ isDisabled: turns.length === 0, onPress: startNewConversation, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { className: "size-4" }) }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": showHistory ? "Hide past conversations" : "Past conversations", "aria-pressed": showHistory, onPress: () => setShowHistory((open) => !open), children: (0, jsx_runtime_1.jsx)(lucide_react_1.History, { className: "size-4" }) }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] }), showHistory ? ((0, jsx_runtime_1.jsx)("div", { className: "min-h-0 flex-1 overflow-y-auto", children: (0, jsx_runtime_1.jsx)(ask_ai_history_js_1.AskAiHistory, { refreshKey: writeCount, activeConversationId: conversationId, onOpenConversation: openConversation }) })) : ((0, jsx_runtime_1.jsxs)("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "space-y-3", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: hint }), currentScreen?.suggestions?.length ? ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap gap-1.5", children: currentScreen.suggestions.map((suggestion) => ((0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "secondary", className: "h-auto rounded-full px-2.5 py-1 text-start text-xs whitespace-normal", isDisabled: isBusy, onPress: () => void send(suggestion), children: suggestion }, suggestion))) })) : null] })), rendered, isBusy && lastTurn?.role === "user" && ((0, jsx_runtime_1.jsxs)("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [(0, jsx_runtime_1.jsx)("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), (0, jsx_runtime_1.jsx)(ThinkingShimmer, {})] })), error && ((0, jsx_runtime_1.jsxs)("div", { role: "alert", className: "flex items-start gap-2 rounded-md border border-danger/30 bg-danger/5 p-2.5 text-xs text-danger", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.TriangleAlert, { className: "mt-px size-3.5 shrink-0", "aria-hidden": true }), (0, jsx_runtime_1.jsx)("p", { className: "flex-1", children: error.message }), error.retry && ((0, jsx_runtime_1.jsxs)(react_2.Button, { size: "sm", variant: "tertiary", className: "h-6 shrink-0 px-2 text-xs", onPress: retry, isDisabled: isBusy, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.RotateCcw, { className: "size-3", "aria-hidden": true }), "Try again"] }))] })), (0, jsx_runtime_1.jsx)("div", { ref: bottomRef })] })), (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", className: "flex-1", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "sr-only", children: placeholder }), (0, jsx_runtime_1.jsx)(react_2.Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Square, { className: "size-3.5" }) })) : ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Send, { className: "size-3.5" }) }))] })] }) })] }));
484
551
  }
485
552
  /** Hoisted for the same reference-equality reason as `ANIMATED` - Streamdown compares it by identity. */
486
553
  const LINK_SAFETY = { enabled: false };
@@ -165,6 +165,7 @@ function ConnectCheckoutStatusRow({ checkout, }) {
165
165
  return ((0, jsx_runtime_1.jsx)(motion_js_1.AnimatePresence, { mode: "wait", initial: false, children: checkout.status !== "idle" && ((0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0, y: 4 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -4 }, transition: { duration: 0.18 }, children: (0, jsx_runtime_1.jsx)(CheckoutStatusRowContent, { checkout: checkout }) }, checkout.status)) }));
166
166
  }
167
167
  function CheckoutStatusRowContent({ checkout, }) {
168
+ const { openConnect } = (0, provider_js_1.useConnect)();
168
169
  if (checkout.status === "waiting") {
169
170
  return ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 rounded-lg border border-separator px-3 py-2 text-sm text-muted", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Loader2, { className: "size-4 shrink-0 animate-spin", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("span", { children: "Finish up in the Connect window. This page updates on its own." })] }));
170
171
  }
@@ -175,7 +176,11 @@ function CheckoutStatusRowContent({ checkout, }) {
175
176
  // are fixed by going to the same URL - so offer it rather than explaining.
176
177
  return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap items-center justify-between gap-3 rounded-lg border border-separator px-3 py-2 text-sm text-muted", children: [(0, jsx_runtime_1.jsx)("span", { children: checkout.status === "blocked"
177
178
  ? "Your browser blocked the Connect window."
178
- : "Nothing changed - the Connect window closed before it was finished." }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [checkout.url && ((0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "secondary", onPress: () => window.open(checkout.url, "_blank", "noopener,noreferrer"), children: ["Open Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })), (0, jsx_runtime_1.jsx)(react_1.Button, { variant: "tertiary", onPress: checkout.reset, children: "Dismiss" })] })] }));
179
+ : "Nothing changed - the Connect window closed before it was finished." }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [checkout.url && ((0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "secondary",
180
+ // Not watched by the checkout poll - that run has ended - but a
181
+ // hand-off all the same: Connect reports back when it is done, and
182
+ // the provider re-reads every resource on this page.
183
+ onPress: () => openConnect(checkout.url), children: ["Open Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })), (0, jsx_runtime_1.jsx)(react_1.Button, { variant: "tertiary", onPress: checkout.reset, children: "Dismiss" })] })] }));
179
184
  }
180
185
  /**
181
186
  * Cancellation, kept out of the plan grid and stated plainly.
@@ -195,9 +200,10 @@ function CheckoutStatusRowContent({ checkout, }) {
195
200
  * version.
196
201
  */
197
202
  function ConnectUnsubscribeRow({ subscription, appUrl, checkout, }) {
203
+ const { openConnect } = (0, provider_js_1.useConnect)();
198
204
  const endsOn = subscription.currentBillingCycle?.endDate;
199
205
  if (subscription.cancelAtPeriodEnd) {
200
- return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap items-center justify-between gap-3 border-t border-separator pt-4", children: [(0, jsx_runtime_1.jsxs)("p", { className: "text-sm text-muted", children: ["Cancels", endsOn ? ` on ${new Date(endsOn).toLocaleDateString()}` : " at the end of this cycle", ". You keep access until then."] }), (0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "tertiary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${subscription.id}`, "_blank", "noopener,noreferrer"), children: ["View on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })] }));
206
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap items-center justify-between gap-3 border-t border-separator pt-4", children: [(0, jsx_runtime_1.jsxs)("p", { className: "text-sm text-muted", children: ["Cancels", endsOn ? ` on ${new Date(endsOn).toLocaleDateString()}` : " at the end of this cycle", ". You keep access until then."] }), (0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "tertiary", onPress: () => openConnect(`/billing/subscriptions/${encodeURIComponent(subscription.id)}`), children: ["View on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })] }));
201
207
  }
202
208
  return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap items-center justify-between gap-3 border-t border-separator pt-4", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Cancel any time. Access continues to the end of the paid cycle." }), (0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "tertiary", className: "text-danger", isDisabled: checkout.status === "waiting", onPress: () => checkout.start({
203
209
  url: (0, use_checkout_js_1.buildCancelUrl)({ appUrl, subscriptionId: subscription.id }),
@@ -72,9 +72,10 @@ function ConnectReadinessBanner({ includeAdvisory = false, hideWhenReady = true,
72
72
  return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsx)(motion_js_1.m.div, { className: (0, cn_js_1.cn)("flex flex-col gap-2", className), role: "status", initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, children: blockers.map((blocker) => ((0, jsx_runtime_1.jsx)(BlockerRow, { blocker: blocker }, `${blocker.code}-${blocker.message}`))) }) }));
73
73
  }
74
74
  function BlockerRow({ blocker }) {
75
+ const { openConnect } = (0, provider_js_1.useConnect)();
75
76
  const inProgress = IN_PROGRESS_CODES.has(blocker.code);
76
77
  const isBlocking = blocker.severity === "blocking";
77
- return ((0, jsx_runtime_1.jsxs)("div", { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm ${inProgress || !isBlocking ? "bg-surface text-muted" : "bg-danger-soft text-danger"}`, children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2", children: [inProgress || !isBlocking ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Info, { className: "size-4 shrink-0", "aria-hidden": "true" })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" })), blocker.message] }), blocker.resolveUrl && !inProgress && ((0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "secondary", onPress: () => window.open(blocker.resolveUrl, "_blank", "noopener,noreferrer"), children: [actionLabel(blocker), (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }))] }));
78
+ return ((0, jsx_runtime_1.jsxs)("div", { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm ${inProgress || !isBlocking ? "bg-surface text-muted" : "bg-danger-soft text-danger"}`, children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2", children: [inProgress || !isBlocking ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Info, { className: "size-4 shrink-0", "aria-hidden": "true" })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" })), blocker.message] }), blocker.resolveUrl && !inProgress && ((0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "secondary", onPress: () => openConnect(blocker.resolveUrl), children: [actionLabel(blocker), (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }))] }));
78
79
  }
79
80
  function ReadyRow({ className }) {
80
81
  return ((0, jsx_runtime_1.jsx)("p", { className: (0, cn_js_1.cn)("text-sm text-muted", className), children: "This workspace is cleared to use every part of the application." }));
@@ -80,13 +80,13 @@ function ConnectNoSubscriptionCard({ plansHref = DEFAULT_PLANS_HREF, title = "Yo
80
80
  * impure and re-renders wrong.
81
81
  */
82
82
  function ConnectSubscriptionCard({ plansHref, className }) {
83
- const { appUrl } = (0, provider_js_1.useConnect)();
83
+ const { openConnect } = (0, provider_js_1.useConnect)();
84
84
  const { isLoading, detail, plan, state } = useSubscriptionState();
85
85
  if (isLoading)
86
86
  return (0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: (0, cn_js_1.cn)("h-48 rounded-xl", className) });
87
87
  if (!detail || !plan)
88
88
  return (0, jsx_runtime_1.jsx)(ConnectNoSubscriptionCard, { plansHref: plansHref, className: className });
89
- return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2 }, children: (0, jsx_runtime_1.jsxs)(react_1.Card, { className: className, children: [(0, jsx_runtime_1.jsxs)(react_1.Card.Header, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-start justify-between gap-2", children: [(0, jsx_runtime_1.jsx)(react_1.Card.Title, { children: plan.name }), (0, jsx_runtime_1.jsx)(SubscriptionChip, { state: state })] }), (0, jsx_runtime_1.jsxs)(react_1.Card.Description, { children: [(0, plan_pricing_js_1.formatPrice)(plan.price), " + ", plan.gstRate, "% GST per", " ", (0, plan_pricing_js_1.intervalLabel)(plan.billingInterval).per] })] }), (0, jsx_runtime_1.jsxs)(react_1.Card.Content, { className: "flex flex-col gap-2 text-sm", children: [(0, jsx_runtime_1.jsx)(NextEvent, { detail: detail, state: state }), (0, jsx_runtime_1.jsx)(ConnectSeatUsage, { plan: plan, detail: detail })] }), (0, jsx_runtime_1.jsxs)(react_1.Card.Footer, { className: "flex flex-wrap gap-2", children: [(0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }), (0, jsx_runtime_1.jsx)(link_button_js_1.ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Change plan" })] })] }) }) }));
89
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2 }, children: (0, jsx_runtime_1.jsxs)(react_1.Card, { className: className, children: [(0, jsx_runtime_1.jsxs)(react_1.Card.Header, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-start justify-between gap-2", children: [(0, jsx_runtime_1.jsx)(react_1.Card.Title, { children: plan.name }), (0, jsx_runtime_1.jsx)(SubscriptionChip, { state: state })] }), (0, jsx_runtime_1.jsxs)(react_1.Card.Description, { children: [(0, plan_pricing_js_1.formatPrice)(plan.price), " + ", plan.gstRate, "% GST per", " ", (0, plan_pricing_js_1.intervalLabel)(plan.billingInterval).per] })] }), (0, jsx_runtime_1.jsxs)(react_1.Card.Content, { className: "flex flex-col gap-2 text-sm", children: [(0, jsx_runtime_1.jsx)(NextEvent, { detail: detail, state: state }), (0, jsx_runtime_1.jsx)(ConnectSeatUsage, { plan: plan, detail: detail })] }), (0, jsx_runtime_1.jsxs)(react_1.Card.Footer, { className: "flex flex-wrap gap-2", children: [(0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "secondary", onPress: () => openConnect(`/billing/subscriptions/${encodeURIComponent(detail.id)}`), children: ["Manage on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }), (0, jsx_runtime_1.jsx)(link_button_js_1.ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Change plan" })] })] }) }) }));
90
90
  }
91
91
  function SubscriptionChip({ state }) {
92
92
  switch (state) {
@@ -157,11 +157,11 @@ function ConnectSeatUsage({ plan, detail, className, }) {
157
157
  * actually cares about anyway.
158
158
  */
159
159
  function ConnectTrialBanner({ plansHref, className }) {
160
- const { appUrl } = (0, provider_js_1.useConnect)();
160
+ const { openConnect } = (0, provider_js_1.useConnect)();
161
161
  const { detail, state } = useSubscriptionState();
162
162
  if (state !== "trialing" || !detail)
163
163
  return null;
164
- return ((0, jsx_runtime_1.jsxs)("div", { className: (0, cn_js_1.cn)("flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", className), children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Clock, { className: "size-4 shrink-0", "aria-hidden": "true" }), "You're on a free trial", detail.nextBillingDate ? `. Your first charge is on ${formatDate(detail.nextBillingDate)}.` : "."] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(link_button_js_1.ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Compare plans" }), (0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })] })] }));
164
+ return ((0, jsx_runtime_1.jsxs)("div", { className: (0, cn_js_1.cn)("flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", className), children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Clock, { className: "size-4 shrink-0", "aria-hidden": "true" }), "You're on a free trial", detail.nextBillingDate ? `. Your first charge is on ${formatDate(detail.nextBillingDate)}.` : "."] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(link_button_js_1.ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Compare plans" }), (0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "secondary", onPress: () => openConnect(`/billing/subscriptions/${encodeURIComponent(detail.id)}`), children: ["Manage", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })] })] }));
165
165
  }
166
166
  /**
167
167
  * Renders `children` for a subscribed workspace, and the "pick a plan" card for