@cosmoops/connect-sdk 0.40.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 +124 -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/call-button.js +398 -0
- 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 +15 -7
- package/dist/cjs/client/portal-links.js +1 -1
- 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 +77 -1
- package/dist/cjs/server/resources/calls.js +58 -0
- 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/call-button.js +358 -0
- 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 +6 -4
- package/dist/esm/client/portal-links.js +1 -1
- 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 +77 -1
- package/dist/esm/server/resources/calls.js +58 -0
- 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/components/call-button.d.ts +132 -0
- package/dist/types/client/handoff.d.ts +62 -0
- package/dist/types/client/index.d.ts +5 -1
- package/dist/types/client/portal-links.d.ts +1 -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/calls.d.ts +32 -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 +152 -1
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,130 @@
|
|
|
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
|
+
|
|
81
|
+
## 0.41.0
|
|
82
|
+
|
|
83
|
+
### Calling from the browser: `<ConnectCallButton>`
|
|
84
|
+
|
|
85
|
+
A signed-in member can now ring a number from your app - through the
|
|
86
|
+
**workspace's own number**, so the person called sees the business, never the
|
|
87
|
+
member's phone. Calls are **not recorded**, and every one lands in Connect's
|
|
88
|
+
call log with who placed it, whether it was answered and how long they talked.
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
import { ConnectCallProvider, ConnectCallButton } from "@cosmoops/connect-sdk/client";
|
|
92
|
+
|
|
93
|
+
// once, inside <ConnectProvider>, beside your app shell - a call outlives the page
|
|
94
|
+
<ConnectCallProvider onCallEnded={({ callId }) => callId && logCall(callId)}>
|
|
95
|
+
{children}
|
|
96
|
+
</ConnectCallProvider>
|
|
97
|
+
|
|
98
|
+
// wherever a person can be rung
|
|
99
|
+
<ConnectCallButton phone="+919812345678" name="Asha Rao" />
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`<ConnectCallProvider>` holds the call and draws the in-call bar (mute, hang
|
|
103
|
+
up, a timer), so navigating mid-call does not hang up. `useConnectCall()` is
|
|
104
|
+
there for your own button. Plivo's browser SDK - now this package's one
|
|
105
|
+
runtime dependency - is loaded only when someone presses Call.
|
|
106
|
+
|
|
107
|
+
**Log the outcome on your server**, not from the browser: `onEnded` /
|
|
108
|
+
`onCallEnded` hand you the call id, and `connect.calls.get(callId)` reads
|
|
109
|
+
`status`, `talkSeconds`, `unansweredReason` and `placedByUserId`. A browser is
|
|
110
|
+
not a witness a call log should trust, and the carrier's report reaches
|
|
111
|
+
Connect a moment after the line drops - read again if `status` is still
|
|
112
|
+
`dialing` or `answered`.
|
|
113
|
+
|
|
114
|
+
**Two halves of permission, both required.** Your API key needs the new
|
|
115
|
+
`calls:place` scope (and `calls:read` to read outcomes); the member needs
|
|
116
|
+
"Make calls" - from their workspace role, or a role on your application
|
|
117
|
+
carrying `connect:call:place`. Nothing here works server-to-server: a call is
|
|
118
|
+
placed by a person. Re-run your seed with the new scopes.
|
|
119
|
+
|
|
120
|
+
New on `ConnectCall`: `handler` (`agent` | `member`), `placedByUserId`,
|
|
121
|
+
`talkSeconds` and `unansweredReason`. `CallStatus` gains `dialing` and
|
|
122
|
+
`unanswered`, which outbound calls already passed through - if you switch on
|
|
123
|
+
it exhaustively, add the two cases.
|
|
124
|
+
|
|
125
|
+
Server-side, `connect.calls` gains `getBrowserCallingToken`,
|
|
126
|
+
`startBrowserCall` and `cancelBrowserCall`; `createConnectHandler` serves
|
|
127
|
+
them as `POST /calls/{browser-token,start,cancel}`. You should not need to
|
|
128
|
+
call either directly.
|
|
129
|
+
|
|
6
130
|
## 0.40.0
|
|
7
131
|
|
|
8
132
|
### `workspaces.create()` exists now, and the switcher's "Create workspace" row is inline
|
|
@@ -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
|
-
* ###
|
|
278
|
+
* ### A hand-off, so it ends back here
|
|
279
279
|
*
|
|
280
|
-
* It used to `window.open(resolveUrl, "_blank")
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
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
|
-
|
|
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: () =>
|
|
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,
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
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
|
|
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)(
|
|
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 = [...
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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 };
|