@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.
- package/CHANGELOG.md +75 -0
- package/dist/cjs/client/components/access-gate.js +18 -40
- package/dist/cjs/client/components/apps-grid.js +11 -3
- package/dist/cjs/client/components/ask-ai-errors.js +109 -0
- package/dist/cjs/client/components/ask-ai-turns.js +12 -0
- package/dist/cjs/client/components/ask-ai.js +75 -8
- package/dist/cjs/client/components/plans.js +8 -2
- package/dist/cjs/client/components/readiness.js +2 -1
- package/dist/cjs/client/components/subscription.js +4 -4
- package/dist/cjs/client/components/user-menu.js +16 -11
- package/dist/cjs/client/handoff.js +78 -0
- package/dist/cjs/client/index.js +9 -6
- package/dist/cjs/client/provider.js +75 -4
- package/dist/cjs/client/use-checkout.js +23 -16
- package/dist/cjs/server/client.js +5 -1
- package/dist/cjs/server/handler.js +4 -1
- package/dist/cjs/server/resources/identity.js +12 -0
- package/dist/cjs/server/resources/whatsapp.js +58 -0
- package/dist/esm/client/components/access-gate.js +18 -40
- package/dist/esm/client/components/apps-grid.js +11 -3
- package/dist/esm/client/components/ask-ai-errors.js +102 -0
- package/dist/esm/client/components/ask-ai-turns.js +12 -0
- package/dist/esm/client/components/ask-ai.js +76 -9
- package/dist/esm/client/components/plans.js +8 -2
- package/dist/esm/client/components/readiness.js +2 -1
- package/dist/esm/client/components/subscription.js +4 -4
- package/dist/esm/client/components/user-menu.js +16 -11
- package/dist/esm/client/handoff.js +72 -0
- package/dist/esm/client/index.js +4 -3
- package/dist/esm/client/provider.js +75 -4
- package/dist/esm/client/use-checkout.js +23 -16
- package/dist/esm/server/client.js +5 -1
- package/dist/esm/server/handler.js +4 -1
- package/dist/esm/server/resources/identity.js +12 -0
- package/dist/esm/server/resources/whatsapp.js +54 -0
- package/dist/types/client/components/ask-ai-errors.d.ts +41 -0
- package/dist/types/client/components/ask-ai-turns.d.ts +5 -0
- package/dist/types/client/handoff.d.ts +62 -0
- package/dist/types/client/index.d.ts +3 -1
- package/dist/types/client/provider.d.ts +72 -5
- package/dist/types/client/use-checkout.d.ts +4 -1
- package/dist/types/server/client.d.ts +4 -1
- package/dist/types/server/resources/identity.d.ts +10 -0
- package/dist/types/server/resources/whatsapp.d.ts +44 -0
- package/dist/types/types.d.ts +42 -0
- 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
|
-
* ###
|
|
274
|
+
* ### A hand-off, so it ends back here
|
|
275
275
|
*
|
|
276
|
-
* It used to `window.open(resolveUrl, "_blank")
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
-
|
|
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: () =>
|
|
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,
|
|
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
|
-
|
|
127
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 = [...
|
|
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(
|
|
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
|
-
|
|
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 &&
|
|
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",
|
|
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: () =>
|
|
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: () =>
|
|
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 {
|
|
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: () =>
|
|
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 {
|
|
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: () =>
|
|
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
|