@tangle-network/sandbox-ui 0.26.0 → 0.27.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.
@@ -1,87 +1,197 @@
1
1
  // src/integrations/integrations-panel.tsx
2
2
  import * as React from "react";
3
- import {
4
- Badge,
5
- Button,
6
- Card,
7
- CardContent,
8
- CardHeader,
9
- EmptyState
10
- } from "@tangle-network/ui/primitives";
3
+ import { Card, CardContent, EmptyState } from "@tangle-network/ui/primitives";
11
4
  import { cn } from "@tangle-network/ui/utils";
12
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
13
- function statusVariant(status) {
14
- if (status === "connected" || status === "ok") return "default";
15
- if (status === "pending") return "secondary";
16
- if (status === "revoked" || status === "expired" || status === "failing")
17
- return "destructive";
18
- return "outline";
19
- }
5
+ import { Check, Search, Settings2 } from "lucide-react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ var DEFAULT_FEATURED_IDS = [
8
+ "gmail",
9
+ "google-sheets",
10
+ "google-drive",
11
+ "google-docs",
12
+ "google-calendar",
13
+ "outlook",
14
+ "outlook-mail",
15
+ "microsoft-calendar",
16
+ "microsoft-excel",
17
+ "microsoft-teams",
18
+ "slack",
19
+ "discord",
20
+ "hubspot",
21
+ "salesforce",
22
+ "notion",
23
+ "airtable",
24
+ "github",
25
+ "gitlab",
26
+ "linear",
27
+ "jira",
28
+ "asana",
29
+ "stripe",
30
+ "stripe-pack",
31
+ "twilio",
32
+ "twilio-sms",
33
+ "linkedin",
34
+ "zoom",
35
+ "shopify",
36
+ "mailchimp",
37
+ "zendesk",
38
+ "intercom",
39
+ "dropbox",
40
+ "webhook"
41
+ ];
20
42
  function defaultConnectorOf(provider) {
21
43
  return provider.connectors?.[0]?.connectorId ?? provider.providerId;
22
44
  }
45
+ function normalizeProviderId(id) {
46
+ return id.toLowerCase().replace(/[_\s]+/g, "-").replace(/-(business|oauth|api|app|mail|sms|pack|connector|v\d+)$/g, "");
47
+ }
23
48
  var PROVIDER_LOGO_SLUGS = {
24
49
  gmail: "gmail",
25
50
  googlemail: "gmail",
26
51
  google: "google",
27
52
  "google-drive": "googledrive",
28
- googledrive: "googledrive",
29
- drive: "googledrive",
30
53
  "google-calendar": "googlecalendar",
31
- googlecalendar: "googlecalendar",
32
- calendar: "googlecalendar",
33
54
  "google-sheets": "googlesheets",
34
- instagram: "instagram",
35
- facebook: "facebook",
36
- meta: "meta",
37
- linkedin: "linkedin",
55
+ "google-docs": "googledocs",
56
+ "google-meet": "googlemeet",
57
+ "google-forms": "googleforms",
58
+ "google-ads": "googleads",
59
+ "google-analytics": "googleanalytics",
60
+ outlook: "microsoftoutlook",
61
+ "outlook-mail": "microsoftoutlook",
62
+ "microsoft-outlook": "microsoftoutlook",
63
+ "microsoft-calendar": "microsoftoutlook",
64
+ "microsoft-teams": "microsoftteams",
65
+ teams: "microsoftteams",
66
+ "microsoft-excel": "microsoftexcel",
67
+ excel: "microsoftexcel",
68
+ onedrive: "microsoftonedrive",
69
+ sharepoint: "microsoftsharepoint",
38
70
  twitter: "x",
39
71
  x: "x",
40
- tiktok: "tiktok",
41
- youtube: "youtube",
42
- slack: "slack",
43
- discord: "discord",
44
- notion: "notion",
72
+ meta: "meta",
73
+ "stripe-pack": "stripe",
74
+ "twilio-sms": "twilio",
75
+ webhook: "webhooks",
76
+ webhooks: "webhooks",
45
77
  hubspot: "hubspot",
46
78
  salesforce: "salesforce",
47
- stripe: "stripe",
48
- github: "github",
49
- gitlab: "gitlab",
50
- linear: "linear",
51
- jira: "jira",
52
- asana: "asana",
53
- trello: "trello",
54
- dropbox: "dropbox",
55
- box: "box",
56
- outlook: "microsoftoutlook",
57
- "microsoft-outlook": "microsoftoutlook",
58
- "microsoft-teams": "microsoftteams",
59
- zoom: "zoom",
60
- shopify: "shopify",
61
- mailchimp: "mailchimp",
62
- airtable: "airtable",
79
+ pipedrive: "pipedrive",
80
+ zoho: "zoho",
81
+ quickbooks: "quickbooks",
82
+ intercom: "intercom",
63
83
  zendesk: "zendesk",
64
- intercom: "intercom"
84
+ freshdesk: "freshdesk",
85
+ monday: "mondaydotcom",
86
+ "monday-com": "mondaydotcom",
87
+ clickup: "clickup",
88
+ basecamp: "basecamp",
89
+ todoist: "todoist",
90
+ calendly: "calendly",
91
+ typeform: "typeform",
92
+ surveymonkey: "surveymonkey",
93
+ klaviyo: "klaviyo",
94
+ sendinblue: "brevo",
95
+ brevo: "brevo",
96
+ "constant-contact": "constantcontact",
97
+ "active-campaign": "activecampaign",
98
+ activecampaign: "activecampaign",
99
+ "google-chat": "googlechat",
100
+ whatsapp: "whatsapp",
101
+ telegram: "telegram",
102
+ bigquery: "googlebigquery",
103
+ snowflake: "snowflake",
104
+ postgres: "postgresql",
105
+ postgresql: "postgresql",
106
+ mysql: "mysql",
107
+ mongodb: "mongodb",
108
+ redis: "redis",
109
+ supabase: "supabase",
110
+ firebase: "firebase",
111
+ "aws-s3": "amazons3",
112
+ s3: "amazons3",
113
+ woocommerce: "woocommerce",
114
+ bigcommerce: "bigcommerce",
115
+ squarespace: "squarespace",
116
+ wix: "wix",
117
+ webflow: "webflow",
118
+ wordpress: "wordpress",
119
+ contentful: "contentful",
120
+ sanity: "sanity",
121
+ figma: "figma",
122
+ miro: "miro",
123
+ confluence: "confluence",
124
+ bitbucket: "bitbucket",
125
+ pagerduty: "pagerduty",
126
+ datadog: "datadog",
127
+ sentry: "sentry",
128
+ segment: "segment",
129
+ amplitude: "amplitude",
130
+ mixpanel: "mixpanel",
131
+ posthog: "posthog",
132
+ facebook: "facebook",
133
+ instagram: "instagram",
134
+ tiktok: "tiktok",
135
+ youtube: "youtube",
136
+ reddit: "reddit",
137
+ pinterest: "pinterest",
138
+ buffer: "buffer",
139
+ hootsuite: "hootsuite"
65
140
  };
66
- function normalizeProviderId(id) {
67
- return id.toLowerCase().replace(/[_\s]+/g, "-").replace(/-(business|oauth|api|app|v\d+)$/g, "");
141
+ function logoCandidates(provider) {
142
+ const out = [];
143
+ if (provider.iconUrl) out.push(provider.iconUrl);
144
+ const raw = provider.providerId.toLowerCase();
145
+ const norm = normalizeProviderId(raw);
146
+ const slugs = /* @__PURE__ */ new Set();
147
+ const curated = PROVIDER_LOGO_SLUGS[raw] ?? PROVIDER_LOGO_SLUGS[norm];
148
+ if (curated) slugs.add(curated);
149
+ slugs.add(norm.replace(/-/g, ""));
150
+ slugs.add(raw.replace(/[-_\s]/g, ""));
151
+ for (const slug of slugs) {
152
+ if (slug) out.push(`https://cdn.simpleicons.org/${slug}`);
153
+ }
154
+ return out;
68
155
  }
69
- function providerLogoUrl(provider) {
70
- if (provider.iconUrl) return provider.iconUrl;
71
- const id = provider.providerId.toLowerCase();
72
- const slug = PROVIDER_LOGO_SLUGS[id] ?? PROVIDER_LOGO_SLUGS[normalizeProviderId(id)];
73
- return slug ? `https://cdn.simpleicons.org/${slug}` : void 0;
156
+ function monogramColor(seed) {
157
+ const palette = [
158
+ "#6366f1",
159
+ "#8b5cf6",
160
+ "#ec4899",
161
+ "#f43f5e",
162
+ "#f97316",
163
+ "#eab308",
164
+ "#22c55e",
165
+ "#14b8a6",
166
+ "#0ea5e9",
167
+ "#3b82f6"
168
+ ];
169
+ let hash = 0;
170
+ for (let i = 0; i < seed.length; i += 1) {
171
+ hash = hash * 31 + seed.charCodeAt(i) >>> 0;
172
+ }
173
+ return palette[hash % palette.length];
74
174
  }
75
- function ProviderLogo({ provider }) {
76
- const [failed, setFailed] = React.useState(false);
77
- const url = providerLogoUrl(provider);
175
+ function ProviderLogo({
176
+ provider,
177
+ size = 56
178
+ }) {
179
+ const candidates = React.useMemo(() => logoCandidates(provider), [provider]);
180
+ const [index, setIndex] = React.useState(0);
78
181
  const label = (provider.displayName ?? provider.providerId).trim();
79
182
  const initial = label.charAt(0).toUpperCase() || "?";
80
- if (!url || failed) {
183
+ const src = candidates[index];
184
+ if (!src) {
81
185
  return /* @__PURE__ */ jsx(
82
186
  "span",
83
187
  {
84
- className: "flex size-6 shrink-0 items-center justify-center rounded bg-muted text-[11px] font-semibold text-muted-foreground",
188
+ className: "flex shrink-0 items-center justify-center rounded-2xl font-semibold text-white",
189
+ style: {
190
+ width: size,
191
+ height: size,
192
+ backgroundColor: monogramColor(provider.providerId),
193
+ fontSize: Math.round(size * 0.42)
194
+ },
85
195
  "aria-hidden": true,
86
196
  children: initial
87
197
  }
@@ -90,13 +200,14 @@ function ProviderLogo({ provider }) {
90
200
  return /* @__PURE__ */ jsx(
91
201
  "img",
92
202
  {
93
- src: url,
203
+ src,
94
204
  alt: "",
95
- width: 24,
96
- height: 24,
205
+ width: size,
206
+ height: size,
97
207
  loading: "lazy",
98
- className: "size-6 shrink-0 rounded object-contain",
99
- onError: () => setFailed(true)
208
+ className: "shrink-0 object-contain",
209
+ style: { width: size, height: size },
210
+ onError: () => setIndex((i) => i + 1)
100
211
  }
101
212
  );
102
213
  }
@@ -108,6 +219,29 @@ function buildConnectionIndex(connections) {
108
219
  }
109
220
  return index;
110
221
  }
222
+ function makeFeaturedRank(featuredIds) {
223
+ const rank = /* @__PURE__ */ new Map();
224
+ featuredIds.forEach((id, i) => {
225
+ const norm = normalizeProviderId(id);
226
+ if (!rank.has(id)) rank.set(id, i);
227
+ if (!rank.has(norm)) rank.set(norm, i);
228
+ });
229
+ return rank;
230
+ }
231
+ function rankOf(provider, rank) {
232
+ const raw = provider.providerId.toLowerCase();
233
+ const direct = rank.get(raw);
234
+ if (direct !== void 0) return direct;
235
+ const norm = rank.get(normalizeProviderId(raw));
236
+ return norm ?? Number.MAX_SAFE_INTEGER;
237
+ }
238
+ function displayNameOf(provider) {
239
+ return provider.displayName ?? provider.providerId.replace(/[-_]/g, " ");
240
+ }
241
+ function matchesQuery(provider, q) {
242
+ const hay = `${provider.displayName ?? ""} ${provider.providerId} ${provider.description ?? ""}`.toLowerCase();
243
+ return hay.includes(q);
244
+ }
111
245
  function IntegrationsPanel({
112
246
  catalog,
113
247
  connections,
@@ -117,12 +251,34 @@ function IntegrationsPanel({
117
251
  onConnect,
118
252
  onDisconnect,
119
253
  emptyCatalogLabel = "No integrations available yet.",
254
+ featuredIds = DEFAULT_FEATURED_IDS,
255
+ defaultSort = "featured",
120
256
  className
121
257
  }) {
258
+ const [query, setQuery] = React.useState("");
259
+ const [sort, setSort] = React.useState(defaultSort);
122
260
  const connectionIndex = React.useMemo(
123
261
  () => buildConnectionIndex(connections),
124
262
  [connections]
125
263
  );
264
+ const featuredRank = React.useMemo(
265
+ () => makeFeaturedRank(featuredIds),
266
+ [featuredIds]
267
+ );
268
+ const visible = React.useMemo(() => {
269
+ const q = query.trim().toLowerCase();
270
+ const filtered = q ? catalog.filter((p) => matchesQuery(p, q)) : catalog;
271
+ const sorted = [...filtered];
272
+ sorted.sort((a, b) => {
273
+ if (sort === "featured") {
274
+ const ra = rankOf(a, featuredRank);
275
+ const rb = rankOf(b, featuredRank);
276
+ if (ra !== rb) return ra - rb;
277
+ }
278
+ return displayNameOf(a).localeCompare(displayNameOf(b));
279
+ });
280
+ return sorted;
281
+ }, [catalog, query, sort, featuredRank]);
126
282
  if (error) {
127
283
  return /* @__PURE__ */ jsx(Card, { className: cn("border-destructive/50", className), children: /* @__PURE__ */ jsx(CardContent, { className: "py-6", children: /* @__PURE__ */ jsxs("p", { className: "text-sm text-destructive", children: [
128
284
  "Failed to load integrations: ",
@@ -130,10 +286,22 @@ function IntegrationsPanel({
130
286
  ] }) }) });
131
287
  }
132
288
  if (isLoading && catalog.length === 0) {
133
- return /* @__PURE__ */ jsx("div", { className: cn("grid gap-3 sm:grid-cols-2 lg:grid-cols-3", className), children: [0, 1, 2, 3].map((i) => /* @__PURE__ */ jsxs(Card, { className: "animate-pulse", children: [
134
- /* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsx("div", { className: "h-5 w-32 rounded bg-muted" }) }),
135
- /* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsx("div", { className: "h-4 w-full rounded bg-muted" }) })
136
- ] }, i)) });
289
+ return /* @__PURE__ */ jsx(
290
+ "div",
291
+ {
292
+ className: cn(
293
+ "grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6",
294
+ className
295
+ ),
296
+ children: Array.from({ length: 12 }).map((_, i) => /* @__PURE__ */ jsx(
297
+ "div",
298
+ {
299
+ className: "aspect-square animate-pulse rounded-xl border border-border bg-muted/40"
300
+ },
301
+ i
302
+ ))
303
+ }
304
+ );
137
305
  }
138
306
  if (catalog.length === 0) {
139
307
  return /* @__PURE__ */ jsx(
@@ -145,61 +313,116 @@ function IntegrationsPanel({
145
313
  }
146
314
  );
147
315
  }
148
- return /* @__PURE__ */ jsx("div", { className: cn("grid gap-3 sm:grid-cols-2 lg:grid-cols-3", className), children: catalog.map((provider) => {
149
- const connectorId = defaultConnectorOf(provider);
150
- const live = connectionIndex.get(`${provider.providerId}:${connectorId}`);
151
- const health = live ? healthByConnectionId?.[live.id] : void 0;
152
- const headline = provider.displayName ?? provider.providerId.replace(/[-_]/g, " ");
153
- return /* @__PURE__ */ jsxs(
154
- Card,
155
- {
156
- "data-testid": `integration-${provider.providerId}`,
157
- children: [
158
- /* @__PURE__ */ jsxs(CardHeader, { className: "flex flex-row items-start justify-between gap-2 space-y-0", children: [
159
- /* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
160
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
161
- /* @__PURE__ */ jsx(ProviderLogo, { provider }),
162
- /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold capitalize text-foreground", children: headline }),
163
- live ? /* @__PURE__ */ jsx(Badge, { variant: statusVariant(live.status), className: "text-xs", children: live.status }) : null
164
- ] }),
165
- provider.description ? /* @__PURE__ */ jsx("p", { className: "line-clamp-2 text-xs text-muted-foreground", children: provider.description }) : null
166
- ] }),
167
- health ? /* @__PURE__ */ jsx(
168
- Badge,
169
- {
170
- variant: statusVariant(health.status),
171
- className: "text-xs uppercase",
172
- children: health.status
173
- }
174
- ) : null
175
- ] }),
176
- /* @__PURE__ */ jsx(CardContent, { className: "flex items-center justify-between gap-2", children: live ? /* @__PURE__ */ jsxs(Fragment, { children: [
177
- /* @__PURE__ */ jsx("span", { className: "truncate text-xs text-muted-foreground", children: live.account?.displayName ?? live.account?.identity ?? "Connected" }),
178
- /* @__PURE__ */ jsx(
179
- Button,
180
- {
181
- size: "sm",
182
- variant: "outline",
183
- onClick: () => onDisconnect(live.id),
184
- "data-testid": `disconnect-${provider.providerId}`,
185
- children: "Disconnect"
186
- }
187
- )
188
- ] }) : /* @__PURE__ */ jsx(
189
- Button,
316
+ return /* @__PURE__ */ jsxs("div", { className: cn("space-y-4", className), children: [
317
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between", children: [
318
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-1 items-center gap-2 rounded-lg border border-border bg-card px-3 py-2", children: [
319
+ /* @__PURE__ */ jsx(Search, { className: "h-4 w-4 shrink-0 text-muted-foreground" }),
320
+ /* @__PURE__ */ jsx(
321
+ "input",
322
+ {
323
+ type: "text",
324
+ value: query,
325
+ onChange: (e) => setQuery(e.target.value),
326
+ placeholder: "Search integrations...",
327
+ autoFocus: true,
328
+ "data-testid": "integration-search",
329
+ className: "flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
330
+ }
331
+ )
332
+ ] }),
333
+ /* @__PURE__ */ jsx(
334
+ "div",
335
+ {
336
+ role: "tablist",
337
+ "aria-label": "Sort integrations",
338
+ className: "flex shrink-0 items-center gap-1 rounded-lg border border-border bg-card p-1",
339
+ children: [
340
+ ["featured", "Featured"],
341
+ ["alpha", "A\u2013Z"]
342
+ ].map(([value, label]) => /* @__PURE__ */ jsx(
343
+ "button",
190
344
  {
191
- size: "sm",
192
- className: "ml-auto",
193
- onClick: () => onConnect({ providerId: provider.providerId, connectorId }),
194
- "data-testid": `connect-${provider.providerId}`,
195
- children: "Connect"
196
- }
197
- ) })
198
- ]
199
- },
200
- `${provider.providerId}:${connectorId}`
201
- );
202
- }) });
345
+ type: "button",
346
+ role: "tab",
347
+ "aria-selected": sort === value,
348
+ onClick: () => setSort(value),
349
+ "data-testid": `sort-${value}`,
350
+ className: cn(
351
+ "rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
352
+ sort === value ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
353
+ ),
354
+ children: label
355
+ },
356
+ value
357
+ ))
358
+ }
359
+ )
360
+ ] }),
361
+ visible.length === 0 ? /* @__PURE__ */ jsx(
362
+ EmptyState,
363
+ {
364
+ title: "No matches",
365
+ description: `No integrations match "${query.trim()}".`
366
+ }
367
+ ) : /* @__PURE__ */ jsx("div", { className: "grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6", children: visible.map((provider) => {
368
+ const connectorId = defaultConnectorOf(provider);
369
+ const live = connectionIndex.get(
370
+ `${provider.providerId}:${connectorId}`
371
+ );
372
+ const name = displayNameOf(provider);
373
+ const connected = Boolean(live);
374
+ if (connected && live) {
375
+ return /* @__PURE__ */ jsxs(
376
+ "div",
377
+ {
378
+ "data-testid": `integration-${provider.providerId}`,
379
+ "data-connected": "true",
380
+ className: cn(
381
+ "group relative flex aspect-square flex-col items-center justify-center gap-2 rounded-xl border p-3 text-center",
382
+ "border-[var(--surface-success-border)] bg-[var(--surface-success-bg)]"
383
+ ),
384
+ children: [
385
+ /* @__PURE__ */ jsx("span", { className: "absolute left-2 top-2 flex h-4 w-4 items-center justify-center rounded-full bg-[var(--surface-success-text)] text-white", children: /* @__PURE__ */ jsx(Check, { className: "h-3 w-3", strokeWidth: 3 }) }),
386
+ /* @__PURE__ */ jsx(
387
+ "button",
388
+ {
389
+ type: "button",
390
+ onClick: () => onDisconnect(live.id),
391
+ "data-testid": `manage-${provider.providerId}`,
392
+ "aria-label": `Manage ${name}`,
393
+ title: "Manage connection",
394
+ className: "absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-background hover:text-foreground group-hover:opacity-100",
395
+ children: /* @__PURE__ */ jsx(Settings2, { className: "h-3.5 w-3.5" })
396
+ }
397
+ ),
398
+ /* @__PURE__ */ jsx(ProviderLogo, { provider, size: 48 }),
399
+ /* @__PURE__ */ jsx("span", { className: "line-clamp-2 w-full text-xs font-medium text-foreground", children: name })
400
+ ]
401
+ },
402
+ `${provider.providerId}:${connectorId}`
403
+ );
404
+ }
405
+ return /* @__PURE__ */ jsxs(
406
+ "button",
407
+ {
408
+ type: "button",
409
+ "data-testid": `integration-${provider.providerId}`,
410
+ "data-connected": "false",
411
+ onClick: () => onConnect({ providerId: provider.providerId, connectorId }),
412
+ title: provider.description ? provider.description : `Connect ${name}`,
413
+ className: cn(
414
+ "group flex aspect-square flex-col items-center justify-center gap-2 rounded-xl border border-border bg-card p-3 text-center transition-all",
415
+ "hover:border-primary/40 hover:bg-accent/40 hover:shadow-sm focus:outline-none focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20"
416
+ ),
417
+ children: [
418
+ /* @__PURE__ */ jsx(ProviderLogo, { provider, size: 56 }),
419
+ /* @__PURE__ */ jsx("span", { className: "line-clamp-2 w-full text-xs font-medium text-foreground", children: name })
420
+ ]
421
+ },
422
+ `${provider.providerId}:${connectorId}`
423
+ );
424
+ }) })
425
+ ] });
203
426
  }
204
427
 
205
428
  // src/integrations/use-integrations.ts
@@ -40,6 +40,13 @@ interface ModelInfo {
40
40
  hostUrl?: string;
41
41
  labUrl?: string;
42
42
  };
43
+ /**
44
+ * Marks a model as recommended. When any catalog row carries this flag the
45
+ * picker surfaces those rows in a "Recommended" section at the top. When no
46
+ * row is flagged the picker falls back to {@link DEFAULT_FEATURED_MODEL_IDS}
47
+ * (matched by dedup key) so the section is never empty for a router catalog.
48
+ */
49
+ featured?: boolean;
43
50
  }
44
51
  type ModelBrandKey = "ai21" | "alibaba" | "anthropic" | "azure" | "bedrock" | "cartesia" | "cerebras" | "cohere" | "deepseek" | "elevenlabs" | "fal" | "fireworks" | "google" | "groq" | "kuaishou" | "luma" | "meta" | "mistral" | "moonshot" | "openai" | "openrouter" | "perplexity" | "pika" | "replicate" | "runway" | "stability" | "tangle" | "tcloud" | "together" | "vertex" | "xai" | "zai" | "unknown";
45
52
  type ModelPickerVariant = "field" | "pill";
@@ -85,10 +92,35 @@ interface ModelPickerProps {
85
92
  * unless the id is already prefixed.
86
93
  */
87
94
  declare function canonicalModelId(model: ModelInfo): string;
95
+ /**
96
+ * Stable key used to collapse catalog duplicates. The Tangle Router lists the
97
+ * same underlying model under several host prefixes (e.g. `openai/gpt-5.4`,
98
+ * `tcloud/gpt-5.4`, `openrouter/openai/gpt-5.4`); they all name one model and
99
+ * should occupy a single row. The key is `<lab>/<model-id>`: the authoring lab
100
+ * (inferred via {@link resolveModelBrandIdentity}, which already maps
101
+ * `gpt-5.4` → openai, `kimi-k2` → moonshot, etc.) plus the final id segment,
102
+ * so a model collapses to one identity regardless of which host serves it.
103
+ */
104
+ declare function modelDedupKey(model: ModelInfo): string;
105
+ /**
106
+ * Collapse catalog duplicates to one row per {@link modelDedupKey}. When a
107
+ * model is served under multiple hosts the preferred row wins: a `featured`
108
+ * row beats a non-featured one, otherwise the first occurrence is kept (so
109
+ * caller-controlled catalog order still decides ties). Input order is
110
+ * preserved for the surviving rows.
111
+ */
112
+ declare function dedupeModels(models: ReadonlyArray<ModelInfo>): ModelInfo[];
113
+ /**
114
+ * Fallback "Recommended" seed — latest frontier models per major lab, matched
115
+ * by {@link modelDedupKey} against the loaded catalog. Used only when no
116
+ * catalog row carries an explicit `featured` flag, so a standard router
117
+ * catalog still gets a curated top section without per-deployment config.
118
+ */
119
+ declare const DEFAULT_FEATURED_MODEL_IDS: ReadonlyArray<string>;
88
120
  /** Format $/M tokens. Returns null if pricing is missing or zero. */
89
121
  declare function formatPricing(pricing: ModelInfo["pricing"]): string | null;
90
122
  /** Format context length compactly (e.g. 200_000 → "200k"). */
91
123
  declare function formatContext(ctx: number | undefined): string | null;
92
124
  declare function ModelPicker({ value, onChange, models, loading, recents, popular, excludeProviders, modalities, variant, label, placeholder, className, triggerClassName, disabled, }: ModelPickerProps): react_jsx_runtime.JSX.Element;
93
125
 
94
- export { type ModelInfo as M, ModelPicker as a, type ModelPickerProps as b, type ModelPickerVariant as c, canonicalModelId as d, formatPricing as e, formatContext as f };
126
+ export { DEFAULT_FEATURED_MODEL_IDS as D, type ModelInfo as M, ModelPicker as a, type ModelPickerProps as b, type ModelPickerVariant as c, canonicalModelId as d, dedupeModels as e, formatContext as f, formatPricing as g, modelDedupKey as m };
package/dist/pages.d.ts CHANGED
@@ -2,7 +2,7 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as React from 'react';
3
3
  import { CSSProperties, ReactNode } from 'react';
4
4
  import { c as BillingSubscription, B as BillingBalance, d as BillingUsage, j as UsageDataPoint, f as PricingTier, g as TemplateCardData } from './template-card-UhV3pmRC.js';
5
- export { M as ModelInfo } from './model-picker-DUfMTQo5.js';
5
+ export { M as ModelInfo } from './model-picker-Cmisf9Y8.js';
6
6
 
7
7
  /**
8
8
  * Shared, self-contained sign-in / sign-up page for every Tangle vertical app.
package/dist/pages.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  Switch,
24
24
  Textarea
25
25
  } from "./chunk-7ZA5SEK3.js";
26
- import "./chunk-JDMX4HHN.js";
26
+ import "./chunk-6NQBODYV.js";
27
27
  import {
28
28
  cn
29
29
  } from "./chunk-EI44GEQ5.js";