@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
@@ -271,26 +271,26 @@ function AccessGateFallback({ state, blocker, className, }) {
271
271
  * to a choice they can act on. Sending them to Connect's own subscribe page
272
272
  * always resolves.
273
273
  *
274
- * ### Same tab, and it carries the way back
274
+ * ### A hand-off, so it ends back here
275
275
  *
276
- * It used to `window.open(resolveUrl, "_blank")`. A new tab is the wrong
277
- * shape for a step you have to finish before you can continue: the customer
278
- * subscribes over there, is left on Connect's billing page with nothing
279
- * pointing home, and the tab they started in is still showing this card. The
280
- * most common ending was two open tabs and the belief that the payment had
281
- * not worked.
276
+ * It used to `window.open(resolveUrl, "_blank")`, and the customer was left on
277
+ * Connect's billing page with nothing pointing home while the tab they
278
+ * started in still showed this card - two open tabs and the belief that the
279
+ * payment had not worked. Then it went same-tab with `?next=`, which only
280
+ * Connect's subscribe page honoured, so every other blocker (a lapsed
281
+ * payment, a missing payment method, verification) still stranded them.
282
282
  *
283
- * So: a same-tab navigation carrying `?next=` back to the page they are
284
- * standing on, which Connect honours after a subscribe once the origin
285
- * matches one registered for this application's client
286
- * (`resolveApplicationReturnUrl`). An unregistered origin costs the return
287
- * trip and nothing else - they still reach Connect, still resolve the
288
- * blocker, and `ConnectAccessGate`'s focus re-check catches them on the way
289
- * back. Partners should register their app's origin; the fallback is a
290
- * degraded path, not a broken one.
283
+ * Now it is `openConnect`, like every other Connect button: Connect brings
284
+ * them back from whichever page the blocker points at, closing itself in
285
+ * `popup` mode or navigating back in `redirect` mode, and the provider
286
+ * re-reads the entitlements the moment it reports in - so this card is
287
+ * replaced by the app without anyone reloading. The focus re-check in
288
+ * `ConnectAccessGate` stays as the backstop for an origin Connect could not
289
+ * validate, which still reaches the blocker and just gets no way back.
291
290
  */
292
291
  function GateCard({ icon: Icon, iconClassName, title, message, actionLabel, resolveUrl, className, }) {
293
- return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: ENTER, children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Icon, { className: `size-5 shrink-0 ${iconClassName}`, "aria-hidden": "true" }), _jsx(Card.Title, { children: title })] }), _jsx(Card.Description, { children: message })] }), resolveUrl && (_jsx(Card.Footer, { children: _jsxs(Button, { variant: "primary", onPress: () => window.location.assign(withReturnTo(resolveUrl)), children: [actionLabel ?? "Resolve on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }) }))] }) }) }));
292
+ const { openConnect } = useConnect();
293
+ return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: ENTER, children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Icon, { className: `size-5 shrink-0 ${iconClassName}`, "aria-hidden": "true" }), _jsx(Card.Title, { children: title })] }), _jsx(Card.Description, { children: message })] }), resolveUrl && (_jsx(Card.Footer, { children: _jsxs(Button, { variant: "primary", onPress: () => openConnect(resolveUrl), children: [actionLabel ?? "Resolve on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }) }))] }) }) }));
294
294
  }
295
295
  /**
296
296
  * The skippable payment-due warning. Dismissal is scoped to this specific
@@ -299,36 +299,14 @@ function GateCard({ icon: Icon, iconClassName, title, message, actionLabel, reso
299
299
  * though an old dismissal is still on record.
300
300
  */
301
301
  function PaymentRetryBanner({ blocker, className, }) {
302
+ const { openConnect } = useConnect();
302
303
  const dismissKey = blocker.graceEndsAt ? `connect_dismissed_payment_retry_pending_${blocker.graceEndsAt}` : null;
303
304
  const [dismissed, setDismissed] = useState(() => dismissKey !== null && typeof window !== "undefined" && window.localStorage.getItem(dismissKey) === "1");
304
305
  if (dismissed)
305
306
  return null;
306
- return (_jsx(MotionProvider, { children: _jsxs(m.div, { className: 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: ENTER, children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" }), blocker.message, blocker.graceEndsAt && ` Resolve by ${formatDate(blocker.graceEndsAt)} to avoid losing access.`] }), _jsxs("div", { className: "flex items-center gap-2", children: [blocker.resolveUrl && (_jsxs(Button, { variant: "secondary", onPress: () => window.open(blocker.resolveUrl, "_blank", "noopener,noreferrer"), children: ["Resolve payment", _jsx(ExternalLink, { className: "size-3.5" })] })), _jsx(Button, { variant: "tertiary", isIconOnly: true, "aria-label": "Dismiss", onPress: () => {
307
+ return (_jsx(MotionProvider, { children: _jsxs(m.div, { className: 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: ENTER, children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" }), blocker.message, blocker.graceEndsAt && ` Resolve by ${formatDate(blocker.graceEndsAt)} to avoid losing access.`] }), _jsxs("div", { className: "flex items-center gap-2", children: [blocker.resolveUrl && (_jsxs(Button, { variant: "secondary", onPress: () => openConnect(blocker.resolveUrl), children: ["Resolve payment", _jsx(ExternalLink, { className: "size-3.5" })] })), _jsx(Button, { variant: "tertiary", isIconOnly: true, "aria-label": "Dismiss", onPress: () => {
307
308
  setDismissed(true);
308
309
  if (dismissKey && typeof window !== "undefined")
309
310
  window.localStorage.setItem(dismissKey, "1");
310
311
  }, children: _jsx(X, { className: "size-4" }) })] })] }) }));
311
312
  }
312
- /**
313
- * `resolveUrl` with `?next=` pointing at the page the customer is standing
314
- * on, so Connect can hand them back once the blocker is gone.
315
- *
316
- * Returns the URL untouched if anything is off - no `window`, an unparseable
317
- * `resolveUrl`, a `next` already set by the server. Getting back is a
318
- * courtesy on top of resolving the blocker, and it must never be the reason
319
- * somebody cannot reach the screen that resolves it.
320
- */
321
- function withReturnTo(resolveUrl) {
322
- if (typeof window === "undefined")
323
- return resolveUrl;
324
- try {
325
- const url = new URL(resolveUrl);
326
- if (url.searchParams.has("next"))
327
- return resolveUrl;
328
- url.searchParams.set("next", window.location.href);
329
- return url.toString();
330
- }
331
- catch {
332
- return resolveUrl;
333
- }
334
- }
@@ -102,7 +102,7 @@ function GridSkeleton() {
102
102
  * button that opens an empty box.
103
103
  */
104
104
  export function ConnectAppsGrid({ className, label = "Switch app", variant = "row" }) {
105
- const { session, isLoading: sessionLoading, appUrl } = useConnect();
105
+ const { session, isLoading: sessionLoading, openConnect } = useConnect();
106
106
  const { data, isLoading, error } = useConnectApps();
107
107
  const [query, setQuery] = useState("");
108
108
  const apps = useMemo(() => data?.apps ?? [], [data]);
@@ -123,8 +123,16 @@ export function ConnectAppsGrid({ className, label = "Switch app", variant = "ro
123
123
  // application's page on Connect, which is where a plan is actually
124
124
  // chosen. The SDK owns this mapping so no integration has to know either
125
125
  // URL shape.
126
- const target = app.subscribed && app.launchUrl ? app.launchUrl : `${appUrl}/apps/${app.applicationId}`;
127
- window.open(target, "_blank", "noopener,noreferrer");
126
+ //
127
+ // The two are different kinds of trip. Launching another application is
128
+ // leaving for somewhere else, so it is a plain new tab with no way back
129
+ // and no `opener`. Choosing a plan on Connect is a task, so it is a
130
+ // hand-off that ends back here.
131
+ if (app.subscribed && app.launchUrl) {
132
+ window.open(app.launchUrl, "_blank", "noopener,noreferrer");
133
+ return;
134
+ }
135
+ openConnect(`/apps/${encodeURIComponent(app.applicationId)}`);
128
136
  };
129
137
  const showSearch = apps.length > SEARCH_THRESHOLD;
130
138
  const isEmpty = !isLoading && !error && mine.length === 0 && explore.length === 0;
@@ -0,0 +1,102 @@
1
+ import { ConnectError } from "../../errors.js";
2
+ /**
3
+ * What Ask AI shows when something fails - a sentence for a person, never the
4
+ * error itself.
5
+ *
6
+ * The raw text is somebody else's diagnostic: `TypeError: Failed to fetch`
7
+ * from the browser, `Connect bridge returned 502` from the provider, a
8
+ * validation message naming a request field. None of it tells the person what
9
+ * happened or what to do, and the last one tells them something about the
10
+ * API they should not need to know. The real error is still on the console of
11
+ * whoever is debugging; this module decides only what is on screen.
12
+ */
13
+ export const ASK_AI_GENERIC_FAILURE = "Something went wrong. Please try again.";
14
+ export const TOOL_GENERIC_FAILURE = "That step couldn't be completed. Try again in a moment.";
15
+ /** Longest partner-written failure shown inline - a sentence or two, not a dump. */
16
+ const MAX_TOOL_FAILURE_CHARS = 280;
17
+ /**
18
+ * A failure Connect reported inside the stream, after it had opened.
19
+ *
20
+ * Kept apart from a transport failure because its text was written for the
21
+ * person by Connect's own stream error handler, so it may be shown as it is.
22
+ */
23
+ export class AskAiStreamError extends Error {
24
+ name = "AskAiStreamError";
25
+ }
26
+ /** The SDK default and this panel's old fallback - both say nothing. */
27
+ const UNINFORMATIVE = new Set(["", "An error occurred.", "Ask AI failed"]);
28
+ /** The browser's own words for "no response arrived", across engines. */
29
+ function isNetworkFailure(cause) {
30
+ if (!(cause instanceof Error))
31
+ return false;
32
+ return (cause.name === "TypeError" &&
33
+ /failed to fetch|fetch failed|load failed|networkerror|network error|network connection was lost/i.test(cause.message));
34
+ }
35
+ export function describeAskAiFailure(cause) {
36
+ if (cause instanceof AskAiStreamError) {
37
+ return UNINFORMATIVE.has(cause.message.trim()) ? ASK_AI_GENERIC_FAILURE : cause.message;
38
+ }
39
+ if (cause instanceof ConnectError) {
40
+ if (cause.status === 401 || cause.isSessionExpired)
41
+ return "Your session has ended. Sign in again to keep asking.";
42
+ if (cause.status === 403)
43
+ return "You don't have access to Ask AI in this workspace. Ask an admin if you think you should.";
44
+ if (cause.status === 429)
45
+ return "That's a lot of questions at once. Wait a moment, then try again.";
46
+ // The one refusal Connect words for a person: Ask AI is switched off for
47
+ // this workspace, and retrying will not change that.
48
+ if (cause.status === 503 && cause.code === "unavailable" && cause.message)
49
+ return cause.message;
50
+ if (cause.status === 0 || cause.status >= 500)
51
+ return "The assistant is having trouble right now. Try again in a moment.";
52
+ return ASK_AI_GENERIC_FAILURE;
53
+ }
54
+ if (isNetworkFailure(cause))
55
+ return "Couldn't reach the assistant. Check your connection, then try again.";
56
+ return ASK_AI_GENERIC_FAILURE;
57
+ }
58
+ /**
59
+ * Whether trying the same question again could help.
60
+ *
61
+ * Off for the failures a retry cannot fix - signed out, no access, Ask AI
62
+ * switched off - so the panel does not offer a button that will fail the same
63
+ * way every time.
64
+ */
65
+ export function isRetryableAskAiFailure(cause) {
66
+ if (cause instanceof ConnectError) {
67
+ return !(cause.status === 401 || cause.status === 403 || cause.isSessionExpired || (cause.status === 503 && cause.code === "unavailable"));
68
+ }
69
+ return true;
70
+ }
71
+ function truncate(text) {
72
+ const trimmed = text.trim();
73
+ return trimmed.length > MAX_TOOL_FAILURE_CHARS ? `${trimmed.slice(0, MAX_TOOL_FAILURE_CHARS - 1)}…` : trimmed;
74
+ }
75
+ /**
76
+ * The failure a tool's *result* reports, or `null` when it succeeded.
77
+ *
78
+ * An application's tool answers `{ isError: true, ... }` rather than throwing,
79
+ * which is MCP's distinction between "the tool refused" and "the call broke".
80
+ * Connect puts its own sentence in `error.message` when it could not reach
81
+ * the application at all; otherwise the application's first text block is its
82
+ * own refusal ("You don't have permission to ..."), written for its own user.
83
+ */
84
+ export function toolFailureFromOutput(output) {
85
+ if (typeof output !== "object" || output === null)
86
+ return null;
87
+ const result = output;
88
+ if (result.isError !== true)
89
+ return null;
90
+ if (typeof result.error?.message === "string" && result.error.message.trim())
91
+ return truncate(result.error.message);
92
+ if (Array.isArray(result.content)) {
93
+ for (const part of result.content) {
94
+ if (part && typeof part === "object" && part.type === "text") {
95
+ const text = part.text;
96
+ if (typeof text === "string" && text.trim())
97
+ return truncate(text);
98
+ }
99
+ }
100
+ }
101
+ return TOOL_GENERIC_FAILURE;
102
+ }
@@ -1,3 +1,4 @@
1
+ import { TOOL_GENERIC_FAILURE, toolFailureFromOutput } from "./ask-ai-errors.js";
1
2
  /**
2
3
  * Index of the final text block, or `-1`. A reverse loop rather than
3
4
  * `findLastIndex`, which is ES2023 - this package targets ES2022 so its
@@ -73,6 +74,7 @@ export function turnsFromStored(turns) {
73
74
  id: typeof part.toolCallId === "string" ? part.toolCallId : `${turn.id}-${blocks.length}`,
74
75
  name: part.type.slice("tool-".length),
75
76
  input: part.input,
77
+ ...storedFailure(part),
76
78
  },
77
79
  ];
78
80
  }
@@ -80,3 +82,13 @@ export function turnsFromStored(turns) {
80
82
  return { id: turn.id, role: turn.role, blocks, reasoning };
81
83
  });
82
84
  }
85
+ /**
86
+ * A stored step's failure, worded exactly as it was live. A thrown error is
87
+ * stored with its raw `errorText`, which is never shown - only the fact.
88
+ */
89
+ function storedFailure(part) {
90
+ if (part.state === "output-error")
91
+ return { failure: TOOL_GENERIC_FAILURE };
92
+ const failure = toolFailureFromOutput(part.output);
93
+ return failure ? { failure } : {};
94
+ }
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { Button, Input, Label, TextField } from "@heroui/react";
5
- import { Brain, ChevronDown, History, Plus, Send, Sparkles, Square, X } from "lucide-react";
5
+ import { Brain, ChevronDown, History, Plus, RotateCcw, Send, Sparkles, Square, TriangleAlert, X } from "lucide-react";
6
6
  import { useConnect } from "../provider.js";
7
7
  /**
8
8
  * The markdown renderer, fetched the first time there is markdown to render.
@@ -32,6 +32,7 @@ import { matchConnectScreen } from "./ai-screens.js";
32
32
  import { AiActions, AiChart, AiDetails, AiMetrics, AiSources, AiSteps, AiTable, AiTimeline } from "./ai-widgets.js";
33
33
  import { AskAiHistory } from "./ask-ai-history.js";
34
34
  import { appendText, lastTextBlockIndex, turnsFromStored, } from "./ask-ai-turns.js";
35
+ import { AskAiStreamError, TOOL_GENERIC_FAILURE, describeAskAiFailure, isRetryableAskAiFailure, toolFailureFromOutput, } from "./ask-ai-errors.js";
35
36
  /**
36
37
  * The panel's open width. The *inner* pane always carries it, so the content
37
38
  * keeps its layout while the outer shell's width animates to zero around it.
@@ -115,6 +116,16 @@ function renderTool(name, input, renderMarkdown, screens, renderLink) {
115
116
  return null;
116
117
  }
117
118
  }
119
+ /**
120
+ * A step that failed, drawn where its result would have been.
121
+ *
122
+ * Small and inline rather than a banner: the turn itself has not failed - the
123
+ * model goes on to say what happened - and one unreachable application should
124
+ * read as one missing piece of an answer, not as the panel breaking.
125
+ */
126
+ function ToolFailure({ message }) {
127
+ return (_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: [_jsx(TriangleAlert, { className: "mt-px size-3.5 shrink-0 text-warning", "aria-hidden": true }), _jsx("p", { children: message })] }));
128
+ }
118
129
  /** Placeholder while a tool's arguments are still arriving, sized like the real thing. */
119
130
  function ToolSkeleton() {
120
131
  return (_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: _jsx(Sparkles, { className: "size-4 text-accent/60", "aria-hidden": true }) }));
@@ -139,7 +150,7 @@ function ToolSkeleton() {
139
150
  async function readStream(response, onChunk) {
140
151
  const body = response.body;
141
152
  if (!body)
142
- throw new Error("Ask AI returned no stream");
153
+ throw new AskAiStreamError("");
143
154
  const reader = body.pipeThrough(new TextDecoderStream()).getReader();
144
155
  let buffer = "";
145
156
  for (;;) {
@@ -185,8 +196,23 @@ async function readStream(response, onChunk) {
185
196
  if (chunk.toolCallId)
186
197
  onChunk({ toolInput: { id: chunk.toolCallId, input: chunk.input } });
187
198
  break;
199
+ case "tool-output-available": {
200
+ // A tool that answered `isError` - an application that could not be
201
+ // reached, or one that refused. Successful output is not drawn: the
202
+ // model's prose is the reading of it.
203
+ const failure = toolFailureFromOutput(chunk.output);
204
+ if (chunk.toolCallId && failure)
205
+ onChunk({ toolFailure: { id: chunk.toolCallId, message: failure } });
206
+ break;
207
+ }
208
+ case "tool-output-error":
209
+ // `errorText` here is a thrown error's raw message - the fact of the
210
+ // failure is shown, its wording never is.
211
+ if (chunk.toolCallId)
212
+ onChunk({ toolFailure: { id: chunk.toolCallId, message: TOOL_GENERIC_FAILURE } });
213
+ break;
188
214
  case "error":
189
- throw new Error(chunk.errorText || "Ask AI failed");
215
+ throw new AskAiStreamError(chunk.errorText ?? "");
190
216
  }
191
217
  }
192
218
  }
@@ -238,6 +264,12 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
238
264
  const [turns, setTurns] = useState([]);
239
265
  const [input, setInput] = useState("");
240
266
  const [isBusy, setIsBusy] = useState(false);
267
+ /**
268
+ * The last request's failure, worded for a person - and the question to ask
269
+ * again, when asking again could help. `retry` is `null` for a failure no
270
+ * retry fixes (signed out, no access, Ask AI switched off), so the panel
271
+ * never offers a button that fails the same way every time.
272
+ */
241
273
  const [error, setError] = useState(null);
242
274
  const [showHistory, setShowHistory] = useState(false);
243
275
  /**
@@ -296,7 +328,13 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
296
328
  abortRef.current = null;
297
329
  setIsBusy(false);
298
330
  }, []);
299
- const send = useCallback(async (text) => {
331
+ const send = useCallback(
332
+ /**
333
+ * `prior` is the conversation the question is asked after - the current
334
+ * one, except on a retry, which drops the failed exchange first so the
335
+ * question is not shown (or sent) twice.
336
+ */
337
+ async (text, prior = turns) => {
300
338
  const controller = new AbortController();
301
339
  abortRef.current = controller;
302
340
  setError(null);
@@ -312,7 +350,7 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
312
350
  // strings. Built from the turns *plus* this one rather than from state,
313
351
  // which has not been committed yet at this point. Only text is sent back:
314
352
  // a widget is a rendering of something the model already said.
315
- const history = [...turns, userTurn].map((turn) => ({
353
+ const history = [...prior, userTurn].map((turn) => ({
316
354
  id: turn.id,
317
355
  role: turn.role,
318
356
  parts: [
@@ -325,7 +363,7 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
325
363
  },
326
364
  ],
327
365
  }));
328
- setTurns((current) => [...current, userTurn]);
366
+ setTurns([...prior, userTurn]);
329
367
  const patchAnswer = (patch) => setTurns((current) => current.map((turn) => (turn.id === answerId ? patch(turn) : turn)));
330
368
  try {
331
369
  // Relative to the provider's own `basePath` - `streamResource` prefixes
@@ -359,6 +397,13 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
359
397
  const { id, name } = chunk.toolStart;
360
398
  patchAnswer((turn) => ({ ...turn, blocks: [...turn.blocks, { kind: "tool", id, name, input: null }] }));
361
399
  }
400
+ if (chunk.toolFailure) {
401
+ const { id, message } = chunk.toolFailure;
402
+ patchAnswer((turn) => ({
403
+ ...turn,
404
+ blocks: turn.blocks.map((block) => block.kind === "tool" && block.id === id ? { ...block, failure: message } : block),
405
+ }));
406
+ }
362
407
  if (chunk.toolInput) {
363
408
  const { id, input: toolInput } = chunk.toolInput;
364
409
  patchAnswer((turn) => ({
@@ -372,7 +417,10 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
372
417
  // Pressing Stop aborts the fetch, which lands here - a decision, not a
373
418
  // failure, and nothing to report.
374
419
  if (!controller.signal.aborted) {
375
- setError(cause instanceof Error ? cause.message : "Ask AI failed");
420
+ // The real error is for whoever is debugging; the panel shows a
421
+ // sentence. See `describeAskAiFailure`.
422
+ console.error("[connect] Ask AI request failed:", cause);
423
+ setError({ message: describeAskAiFailure(cause), retry: isRetryableAskAiFailure(cause) ? text : null });
376
424
  }
377
425
  }
378
426
  finally {
@@ -414,6 +462,25 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
414
462
  setConversationId(id);
415
463
  setShowHistory(false);
416
464
  }, []);
465
+ /**
466
+ * Asks the failed question again, in place of the failed exchange.
467
+ *
468
+ * Everything from the last question onwards is dropped - the question and
469
+ * whatever half-answer arrived before the failure - so the retry reads as
470
+ * the same question answered, not as the person asking twice.
471
+ */
472
+ const retry = useCallback(() => {
473
+ if (!error?.retry || isBusy)
474
+ return;
475
+ let cut = turns.length;
476
+ for (let index = turns.length - 1; index >= 0; index--) {
477
+ if (turns[index]?.role === "user") {
478
+ cut = index;
479
+ break;
480
+ }
481
+ }
482
+ void send(error.retry, turns.slice(0, cut));
483
+ }, [error, isBusy, turns, send]);
417
484
  const lastTurn = turns.at(-1);
418
485
  const isAnswering = isBusy && lastTurn?.role === "assistant";
419
486
  const rendered = useMemo(() => turns.map((turn) => {
@@ -423,7 +490,7 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
423
490
  // The user's own turn is shown as they typed it - running a
424
491
  // question that happens to start with `#` through the markdown
425
492
  // renderer would turn it into a heading.
426
- _jsx("div", { children: turn.role === "user" ? (_jsx("p", { className: "whitespace-pre-wrap", children: block.text })) : (renderMarkdown(block.text, isLive && index === lastTextIndex)) }, index)) : block.input === null ? (_jsx(ToolSkeleton, {}, block.id)) : (_jsx("div", { children: renderTool(block.name, block.input, renderMarkdown, screens, renderLink) }, block.id))), isLive && lastTextIndex === -1 && turn.blocks.length === 0 && _jsx(ThinkingShimmer, {})] }, turn.id));
493
+ _jsx("div", { children: turn.role === "user" ? (_jsx("p", { className: "whitespace-pre-wrap", children: block.text })) : (renderMarkdown(block.text, isLive && index === lastTextIndex)) }, index)) : block.failure ? (_jsx(ToolFailure, { message: block.failure }, block.id)) : block.input === null ? (_jsx(ToolSkeleton, {}, block.id)) : (_jsx("div", { children: renderTool(block.name, block.input, renderMarkdown, screens, renderLink) }, block.id))), isLive && lastTextIndex === -1 && turn.blocks.length === 0 && _jsx(ThinkingShimmer, {})] }, turn.id));
427
494
  }), [turns, isAnswering, lastTurn?.id, assistantName, renderMarkdown]);
428
495
  // Signed out there is no workspace to ground an answer in, and the bridge
429
496
  // would answer 401 - so there is nothing to offer, not even a button.
@@ -444,7 +511,7 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
444
511
  // Disabled on an empty one rather than hidden: a control that
445
512
  // appears and disappears as you use the panel is harder to find
446
513
  // than one that is always in the same place.
447
- isDisabled: turns.length === 0, onPress: startNewConversation, children: _jsx(Plus, { className: "size-4" }) }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": showHistory ? "Hide past conversations" : "Past conversations", "aria-pressed": showHistory, onPress: () => setShowHistory((open) => !open), children: _jsx(History, { className: "size-4" }) }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: _jsx(X, { className: "size-4" }) })] }), showHistory ? (_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(AskAiHistory, { refreshKey: writeCount, activeConversationId: conversationId, onOpenConversation: openConversation }) })) : (_jsxs("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && (_jsxs("div", { className: "space-y-3", children: [_jsx("p", { className: "text-sm text-muted", children: hint }), currentScreen?.suggestions?.length ? (_jsx("div", { className: "flex flex-wrap gap-1.5", children: currentScreen.suggestions.map((suggestion) => (_jsx(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" && (_jsxs("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [_jsx("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), _jsx(ThinkingShimmer, {})] })), error && _jsx("p", { className: "text-xs text-danger", children: error }), _jsx("div", { ref: bottomRef })] })), _jsxs("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [_jsxs(TextField, { variant: "secondary", className: "flex-1", children: [_jsx(Label, { className: "sr-only", children: placeholder }), _jsx(Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? (_jsx(Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: _jsx(Square, { className: "size-3.5" }) })) : (_jsx(Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: _jsx(Send, { className: "size-3.5" }) }))] })] }) })] }));
514
+ isDisabled: turns.length === 0, onPress: startNewConversation, children: _jsx(Plus, { className: "size-4" }) }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": showHistory ? "Hide past conversations" : "Past conversations", "aria-pressed": showHistory, onPress: () => setShowHistory((open) => !open), children: _jsx(History, { className: "size-4" }) }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: _jsx(X, { className: "size-4" }) })] }), showHistory ? (_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(AskAiHistory, { refreshKey: writeCount, activeConversationId: conversationId, onOpenConversation: openConversation }) })) : (_jsxs("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && (_jsxs("div", { className: "space-y-3", children: [_jsx("p", { className: "text-sm text-muted", children: hint }), currentScreen?.suggestions?.length ? (_jsx("div", { className: "flex flex-wrap gap-1.5", children: currentScreen.suggestions.map((suggestion) => (_jsx(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" && (_jsxs("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [_jsx("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), _jsx(ThinkingShimmer, {})] })), error && (_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: [_jsx(TriangleAlert, { className: "mt-px size-3.5 shrink-0", "aria-hidden": true }), _jsx("p", { className: "flex-1", children: error.message }), error.retry && (_jsxs(Button, { size: "sm", variant: "tertiary", className: "h-6 shrink-0 px-2 text-xs", onPress: retry, isDisabled: isBusy, children: [_jsx(RotateCcw, { className: "size-3", "aria-hidden": true }), "Try again"] }))] })), _jsx("div", { ref: bottomRef })] })), _jsxs("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [_jsxs(TextField, { variant: "secondary", className: "flex-1", children: [_jsx(Label, { className: "sr-only", children: placeholder }), _jsx(Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? (_jsx(Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: _jsx(Square, { className: "size-3.5" }) })) : (_jsx(Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: _jsx(Send, { className: "size-3.5" }) }))] })] }) })] }));
448
515
  }
449
516
  /** Hoisted for the same reference-equality reason as `ANIMATED` - Streamdown compares it by identity. */
450
517
  const LINK_SAFETY = { enabled: false };
@@ -162,6 +162,7 @@ function ConnectCheckoutStatusRow({ checkout, }) {
162
162
  return (_jsx(AnimatePresence, { mode: "wait", initial: false, children: checkout.status !== "idle" && (_jsx(m.div, { initial: { opacity: 0, y: 4 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -4 }, transition: { duration: 0.18 }, children: _jsx(CheckoutStatusRowContent, { checkout: checkout }) }, checkout.status)) }));
163
163
  }
164
164
  function CheckoutStatusRowContent({ checkout, }) {
165
+ const { openConnect } = useConnect();
165
166
  if (checkout.status === "waiting") {
166
167
  return (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-separator px-3 py-2 text-sm text-muted", children: [_jsx(Loader2, { className: "size-4 shrink-0 animate-spin", "aria-hidden": "true" }), _jsx("span", { children: "Finish up in the Connect window. This page updates on its own." })] }));
167
168
  }
@@ -172,7 +173,11 @@ function CheckoutStatusRowContent({ checkout, }) {
172
173
  // are fixed by going to the same URL - so offer it rather than explaining.
173
174
  return (_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: [_jsx("span", { children: checkout.status === "blocked"
174
175
  ? "Your browser blocked the Connect window."
175
- : "Nothing changed - the Connect window closed before it was finished." }), _jsxs("div", { className: "flex items-center gap-2", children: [checkout.url && (_jsxs(Button, { variant: "secondary", onPress: () => window.open(checkout.url, "_blank", "noopener,noreferrer"), children: ["Open Connect", _jsx(ExternalLink, { className: "size-3.5" })] })), _jsx(Button, { variant: "tertiary", onPress: checkout.reset, children: "Dismiss" })] })] }));
176
+ : "Nothing changed - the Connect window closed before it was finished." }), _jsxs("div", { className: "flex items-center gap-2", children: [checkout.url && (_jsxs(Button, { variant: "secondary",
177
+ // Not watched by the checkout poll - that run has ended - but a
178
+ // hand-off all the same: Connect reports back when it is done, and
179
+ // the provider re-reads every resource on this page.
180
+ onPress: () => openConnect(checkout.url), children: ["Open Connect", _jsx(ExternalLink, { className: "size-3.5" })] })), _jsx(Button, { variant: "tertiary", onPress: checkout.reset, children: "Dismiss" })] })] }));
176
181
  }
177
182
  /**
178
183
  * Cancellation, kept out of the plan grid and stated plainly.
@@ -192,9 +197,10 @@ function CheckoutStatusRowContent({ checkout, }) {
192
197
  * version.
193
198
  */
194
199
  function ConnectUnsubscribeRow({ subscription, appUrl, checkout, }) {
200
+ const { openConnect } = useConnect();
195
201
  const endsOn = subscription.currentBillingCycle?.endDate;
196
202
  if (subscription.cancelAtPeriodEnd) {
197
- return (_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3 border-t border-separator pt-4", children: [_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."] }), _jsxs(Button, { variant: "tertiary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${subscription.id}`, "_blank", "noopener,noreferrer"), children: ["View on Connect", _jsx(ExternalLink, { className: "size-3.5" })] })] }));
203
+ return (_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3 border-t border-separator pt-4", children: [_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."] }), _jsxs(Button, { variant: "tertiary", onPress: () => openConnect(`/billing/subscriptions/${encodeURIComponent(subscription.id)}`), children: ["View on Connect", _jsx(ExternalLink, { className: "size-3.5" })] })] }));
198
204
  }
199
205
  return (_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3 border-t border-separator pt-4", children: [_jsx("p", { className: "text-sm text-muted", children: "Cancel any time. Access continues to the end of the paid cycle." }), _jsxs(Button, { variant: "tertiary", className: "text-danger", isDisabled: checkout.status === "waiting", onPress: () => checkout.start({
200
206
  url: buildCancelUrl({ appUrl, subscriptionId: subscription.id }),
@@ -68,9 +68,10 @@ export function ConnectReadinessBanner({ includeAdvisory = false, hideWhenReady
68
68
  return (_jsx(MotionProvider, { children: _jsx(m.div, { className: cn("flex flex-col gap-2", className), role: "status", initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: ENTER, children: blockers.map((blocker) => (_jsx(BlockerRow, { blocker: blocker }, `${blocker.code}-${blocker.message}`))) }) }));
69
69
  }
70
70
  function BlockerRow({ blocker }) {
71
+ const { openConnect } = useConnect();
71
72
  const inProgress = IN_PROGRESS_CODES.has(blocker.code);
72
73
  const isBlocking = blocker.severity === "blocking";
73
- return (_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: [_jsxs("span", { className: "flex items-center gap-2", children: [inProgress || !isBlocking ? (_jsx(Info, { className: "size-4 shrink-0", "aria-hidden": "true" })) : (_jsx(AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" })), blocker.message] }), blocker.resolveUrl && !inProgress && (_jsxs(Button, { variant: "secondary", onPress: () => window.open(blocker.resolveUrl, "_blank", "noopener,noreferrer"), children: [actionLabel(blocker), _jsx(ExternalLink, { className: "size-3.5" })] }))] }));
74
+ return (_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: [_jsxs("span", { className: "flex items-center gap-2", children: [inProgress || !isBlocking ? (_jsx(Info, { className: "size-4 shrink-0", "aria-hidden": "true" })) : (_jsx(AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" })), blocker.message] }), blocker.resolveUrl && !inProgress && (_jsxs(Button, { variant: "secondary", onPress: () => openConnect(blocker.resolveUrl), children: [actionLabel(blocker), _jsx(ExternalLink, { className: "size-3.5" })] }))] }));
74
75
  }
75
76
  function ReadyRow({ className }) {
76
77
  return (_jsx("p", { className: cn("text-sm text-muted", className), children: "This workspace is cleared to use every part of the application." }));
@@ -72,13 +72,13 @@ export function ConnectNoSubscriptionCard({ plansHref = DEFAULT_PLANS_HREF, titl
72
72
  * impure and re-renders wrong.
73
73
  */
74
74
  export function ConnectSubscriptionCard({ plansHref, className }) {
75
- const { appUrl } = useConnect();
75
+ const { openConnect } = useConnect();
76
76
  const { isLoading, detail, plan, state } = useSubscriptionState();
77
77
  if (isLoading)
78
78
  return _jsx(Skeleton, { className: cn("h-48 rounded-xl", className) });
79
79
  if (!detail || !plan)
80
80
  return _jsx(ConnectNoSubscriptionCard, { plansHref: plansHref, className: className });
81
- return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2 }, children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: plan.name }), _jsx(SubscriptionChip, { state: state })] }), _jsxs(Card.Description, { children: [formatPrice(plan.price), " + ", plan.gstRate, "% GST per", " ", intervalLabel(plan.billingInterval).per] })] }), _jsxs(Card.Content, { className: "flex flex-col gap-2 text-sm", children: [_jsx(NextEvent, { detail: detail, state: state }), _jsx(ConnectSeatUsage, { plan: plan, detail: detail })] }), _jsxs(Card.Footer, { className: "flex flex-wrap gap-2", children: [_jsxs(Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }), _jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Change plan" })] })] }) }) }));
81
+ return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2 }, children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: plan.name }), _jsx(SubscriptionChip, { state: state })] }), _jsxs(Card.Description, { children: [formatPrice(plan.price), " + ", plan.gstRate, "% GST per", " ", intervalLabel(plan.billingInterval).per] })] }), _jsxs(Card.Content, { className: "flex flex-col gap-2 text-sm", children: [_jsx(NextEvent, { detail: detail, state: state }), _jsx(ConnectSeatUsage, { plan: plan, detail: detail })] }), _jsxs(Card.Footer, { className: "flex flex-wrap gap-2", children: [_jsxs(Button, { variant: "secondary", onPress: () => openConnect(`/billing/subscriptions/${encodeURIComponent(detail.id)}`), children: ["Manage on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }), _jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Change plan" })] })] }) }) }));
82
82
  }
83
83
  function SubscriptionChip({ state }) {
84
84
  switch (state) {
@@ -149,11 +149,11 @@ export function ConnectSeatUsage({ plan, detail, className, }) {
149
149
  * actually cares about anyway.
150
150
  */
151
151
  export function ConnectTrialBanner({ plansHref, className }) {
152
- const { appUrl } = useConnect();
152
+ const { openConnect } = useConnect();
153
153
  const { detail, state } = useSubscriptionState();
154
154
  if (state !== "trialing" || !detail)
155
155
  return null;
156
- return (_jsxs("div", { className: 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: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(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)}.` : "."] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Compare plans" }), _jsxs(Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage", _jsx(ExternalLink, { className: "size-3.5" })] })] })] }));
156
+ return (_jsxs("div", { className: 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: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(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)}.` : "."] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Compare plans" }), _jsxs(Button, { variant: "secondary", onPress: () => openConnect(`/billing/subscriptions/${encodeURIComponent(detail.id)}`), children: ["Manage", _jsx(ExternalLink, { className: "size-3.5" })] })] })] }));
157
157
  }
158
158
  /**
159
159
  * Renders `children` for a subscribed workspace, and the "pick a plan" card for