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