@soba-so/react 0.0.27 → 0.0.28

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/dist/styles.css CHANGED
@@ -1533,6 +1533,7 @@ a.sc-btn {
1533
1533
 
1534
1534
  .sc-mp-label {
1535
1535
  min-width: 0;
1536
+ max-width: 24ch;
1536
1537
  overflow: hidden;
1537
1538
  text-overflow: ellipsis;
1538
1539
  white-space: nowrap;
@@ -1544,6 +1545,274 @@ a.sc-btn {
1544
1545
  color: var(--sc-faint, #a1a1aa);
1545
1546
  }
1546
1547
 
1548
+ .sc-mm {
1549
+ position: fixed;
1550
+ inset: auto;
1551
+ z-index: 2147483647;
1552
+ display: flex;
1553
+ flex-direction: column;
1554
+ min-width: calc(240px * var(--sc-scale, 1));
1555
+ max-width: min(calc(340px * var(--sc-scale, 1)), calc(100vw - 16px));
1556
+ width: max-content;
1557
+ margin: 0;
1558
+ padding: 0;
1559
+ overflow: hidden;
1560
+ border: 1px solid var(--sc-line, #e9e9eb);
1561
+ border-radius: calc(12px * var(--sc-scale, 1));
1562
+ background: var(--sc-tray-card, #ffffff);
1563
+ box-shadow:
1564
+ 0 12px 32px rgba(0, 0, 0, 0.12),
1565
+ 0 2px 6px rgba(0, 0, 0, 0.06);
1566
+ color: var(--sc-text, #1a1a1c);
1567
+ font-size: var(--sc-fs-small, 13px);
1568
+ line-height: 1.3;
1569
+ outline: none;
1570
+ }
1571
+
1572
+ .sc-mm-search {
1573
+ display: flex;
1574
+ flex: none;
1575
+ align-items: center;
1576
+ gap: 8px;
1577
+ padding: 0 12px;
1578
+ border-bottom: 1px solid var(--sc-line, #e9e9eb);
1579
+ color: var(--sc-faint, #a1a1aa);
1580
+ }
1581
+
1582
+ .sc-mm-find {
1583
+ flex: none;
1584
+ width: 14px;
1585
+ height: 14px;
1586
+ }
1587
+
1588
+ .sc-mm-search input {
1589
+ flex: 1;
1590
+ min-width: 0;
1591
+ height: calc(38px * var(--sc-scale, 1));
1592
+ padding: 0;
1593
+ border: 0;
1594
+ background: transparent;
1595
+ font: inherit;
1596
+
1597
+ font-size: max(16px, var(--sc-fs-small, 13px));
1598
+ color: var(--sc-text, #1a1a1c);
1599
+ outline: none;
1600
+ }
1601
+ @media (pointer: fine) {
1602
+ .sc-mm-search input {
1603
+ font-size: inherit;
1604
+ }
1605
+ }
1606
+ .sc-mm-search input::placeholder {
1607
+ color: var(--sc-faint, #a1a1aa);
1608
+ }
1609
+
1610
+ .sc-mm-list {
1611
+ flex: 1;
1612
+ min-height: 0;
1613
+ padding: 4px;
1614
+ overflow-y: auto;
1615
+ overscroll-behavior: contain;
1616
+ scroll-padding-block: 4px;
1617
+ }
1618
+
1619
+ .sc-menu,
1620
+ .sc-mm-list,
1621
+ .sc-mm-flyout {
1622
+ scrollbar-width: none;
1623
+ }
1624
+ .sc-menu::-webkit-scrollbar,
1625
+ .sc-mm-list::-webkit-scrollbar,
1626
+ .sc-mm-flyout::-webkit-scrollbar {
1627
+ display: none;
1628
+ }
1629
+
1630
+ .sc-mm-group + .sc-mm-group {
1631
+ margin-top: 4px;
1632
+ padding-top: 4px;
1633
+ border-top: 1px solid var(--sc-line, #e9e9eb);
1634
+ }
1635
+
1636
+ .sc-mm-head {
1637
+ display: flex;
1638
+ align-items: center;
1639
+ gap: 6px;
1640
+ padding: 8px 8px 4px;
1641
+ font-size: var(--sc-fs-label);
1642
+ font-weight: 500;
1643
+ color: var(--sc-muted, #6b6b73);
1644
+ }
1645
+
1646
+ .sc-mm-logo {
1647
+ flex: none;
1648
+ width: 14px;
1649
+ height: 14px;
1650
+ border-radius: 3px;
1651
+ }
1652
+
1653
+ .sc-mm-emoji {
1654
+ display: inline-grid;
1655
+ place-items: center;
1656
+ font-size: 12px;
1657
+ line-height: 1;
1658
+ }
1659
+
1660
+ .sc-mm-item {
1661
+ display: flex;
1662
+ align-items: center;
1663
+ gap: 8px;
1664
+ min-height: calc(32px * var(--sc-scale, 1));
1665
+ padding: 0 8px;
1666
+ border-radius: calc(7px * var(--sc-scale, 1));
1667
+ cursor: pointer;
1668
+ user-select: none;
1669
+ }
1670
+
1671
+ .sc-mm-item[data-active] {
1672
+ background: var(--sc-tray-sunk, #f4f4f5);
1673
+ }
1674
+
1675
+ .sc-mm-inline {
1676
+ position: static;
1677
+ z-index: auto;
1678
+ max-height: 380px;
1679
+ }
1680
+
1681
+ .sc-mm-item[data-locked] {
1682
+ cursor: default;
1683
+ }
1684
+
1685
+ .sc-mm-item[data-locked] .sc-mm-label {
1686
+ color: var(--sc-muted, #6b6b73);
1687
+ }
1688
+
1689
+ .sc-mm-label {
1690
+ flex: none;
1691
+ max-width: 100%;
1692
+ overflow: hidden;
1693
+ text-overflow: ellipsis;
1694
+ white-space: nowrap;
1695
+ }
1696
+
1697
+ .sc-mm-item[aria-selected="true"] .sc-mm-label {
1698
+ font-weight: 500;
1699
+ }
1700
+
1701
+ .sc-mm-note {
1702
+ flex: 1;
1703
+ min-width: 0;
1704
+ overflow: hidden;
1705
+ text-overflow: ellipsis;
1706
+ white-space: nowrap;
1707
+ color: var(--sc-muted, #6b6b73);
1708
+ }
1709
+
1710
+ .sc-mm-item .sc-menu-check {
1711
+ margin-left: auto;
1712
+ width: 14px;
1713
+ height: 14px;
1714
+ }
1715
+
1716
+ .sc-mm-empty {
1717
+ padding: 16px 8px;
1718
+ text-align: center;
1719
+ color: var(--sc-muted, #6b6b73);
1720
+ }
1721
+
1722
+ .sc-mm-tile {
1723
+ flex: none;
1724
+ width: 16px;
1725
+ height: 16px;
1726
+ border-radius: 4px;
1727
+ object-fit: cover;
1728
+ }
1729
+ .sc-mm-tile-box {
1730
+ display: inline-grid;
1731
+ place-items: center;
1732
+ background: color-mix(in srgb, currentColor 6%, transparent);
1733
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 10%, transparent);
1734
+ color: var(--sc-text, #1a1a1c);
1735
+ }
1736
+ .sc-mm-tile-glyph {
1737
+ width: 11px;
1738
+ height: 11px;
1739
+ }
1740
+ .sc-mm-tile-dim {
1741
+ opacity: 0.5;
1742
+ filter: grayscale(1);
1743
+ }
1744
+ .sc-mm-tile.sc-mm-emoji {
1745
+ font-size: 11px;
1746
+ }
1747
+
1748
+ .sc-mm-sub .sc-chev {
1749
+ flex: none;
1750
+ width: 14px;
1751
+ height: 14px;
1752
+ margin-left: auto;
1753
+ color: var(--sc-muted, #6b6b73);
1754
+ }
1755
+ .sc-mm-sub .sc-mm-label {
1756
+ flex: 1;
1757
+ min-width: 0;
1758
+ }
1759
+ .sc-mm-sub[data-locked] .sc-mm-tile {
1760
+ opacity: 0.5;
1761
+ filter: grayscale(1);
1762
+ }
1763
+
1764
+ .sc-mm-pill {
1765
+ flex: none;
1766
+ margin-left: auto;
1767
+ padding: 1px 6px;
1768
+ border-radius: 999px;
1769
+ box-shadow: inset 0 0 0 1px var(--sc-line, #e9e9eb);
1770
+ font-size: 10px;
1771
+ font-weight: 500;
1772
+ letter-spacing: 0.02em;
1773
+ color: var(--sc-muted, #6b6b73);
1774
+ }
1775
+ .sc-mm-sub .sc-mm-pill {
1776
+ margin-left: 0;
1777
+ }
1778
+
1779
+ .sc-mm-dot {
1780
+ flex: none;
1781
+ width: 6px;
1782
+ height: 6px;
1783
+ border-radius: 999px;
1784
+ background: color-mix(in srgb, var(--sc-text, #1a1a1c) 60%, transparent);
1785
+ }
1786
+
1787
+ .sc-mm-rule {
1788
+ height: 1px;
1789
+ margin: 4px -4px;
1790
+ background: var(--sc-line, #e9e9eb);
1791
+ }
1792
+ .sc-mm-label-row {
1793
+ padding: 6px 8px 4px;
1794
+ font-size: var(--sc-fs-label, 12px);
1795
+ color: var(--sc-muted, #6b6b73);
1796
+ }
1797
+
1798
+ .sc-mm-flyout {
1799
+ position: fixed;
1800
+ z-index: 1;
1801
+ padding: 4px;
1802
+ overflow-y: auto;
1803
+ overscroll-behavior: contain;
1804
+ border: 1px solid var(--sc-line, #e9e9eb);
1805
+ border-radius: calc(12px * var(--sc-scale, 1));
1806
+ background: var(--sc-tray-card, #ffffff);
1807
+ box-shadow:
1808
+ 0 12px 32px rgba(0, 0, 0, 0.12),
1809
+ 0 2px 6px rgba(0, 0, 0, 0.06);
1810
+ }
1811
+ .sc-mm-inline {
1812
+ position: relative;
1813
+ overflow: visible;
1814
+ }
1815
+
1547
1816
  .sc-atile .sc-runtime-logo {
1548
1817
  width: calc(25px * var(--sc-scale, 1));
1549
1818
  height: calc(25px * var(--sc-scale, 1));
@@ -1936,6 +2205,19 @@ a.sc-btn {
1936
2205
  color: var(--sc-muted, #6b6b73);
1937
2206
  }
1938
2207
 
2208
+ .sc-plan-feature::before {
2209
+ content: "✓ ";
2210
+ color: var(--sc-text, currentColor);
2211
+ }
2212
+
2213
+ .sc-plan-feature-absent {
2214
+ text-decoration: line-through;
2215
+ opacity: 0.6;
2216
+ }
2217
+ .sc-plan-feature-absent::before {
2218
+ content: "✕ ";
2219
+ }
2220
+
1939
2221
  .sc-plan-badge {
1940
2222
  font-size: var(--sc-fs-label, 11px);
1941
2223
  padding: 2px 6px;
package/dist/types.d.ts CHANGED
@@ -127,9 +127,10 @@ export type Launch =
127
127
  * Derived from what they have connected rather than configured anywhere: see
128
128
  * `useModel()`, and `models.ts` in the panel for the three facts it reconciles.
129
129
  */
130
- export type ModelOptionKind = "auto" | "size" | "named";
130
+ export type ModelOptionKind = "auto" | "size" | "named" | "provider";
131
131
  export interface ModelOption {
132
- /** `auto`, `small`, `large`, or `<runtime>/<model>`. */
132
+ /** `auto`, `small`, `large`, `<runtime>/<model>`, or `@<provider>/<model>`
133
+ * for one of the app's own providers. */
133
134
  id: string;
134
135
  kind: ModelOptionKind;
135
136
  /** `Automatic`, `Fast`, `Opus`, `llama3.2:3b`. */
@@ -153,6 +154,87 @@ export interface ModelOption {
153
154
  costClass?: string;
154
155
  /** The machine advertising it, when exactly one of theirs does. */
155
156
  machine?: string;
157
+ /** The app's provider serving it, on a provider's model only. */
158
+ providerId?: string;
159
+ /** That provider's name, as the app's owner gave it. */
160
+ providerName?: string;
161
+ /** Whose API it is — `openai`, `groq` — for `<ProviderMark />`. */
162
+ providerMark?: string;
163
+ /** Listed and not pickable: the plan does not reach this provider, or its
164
+ * allowance is used. Draw it as an upgrade, never select it. */
165
+ locked?: "plan" | "allowance";
166
+ /** The app's own entry this was made from — a name set on Providers (its
167
+ * alias) or a `ModelChoice.id` in code: the app's words on a real model. */
168
+ choice?: string;
169
+ /** A name's run may land on any of these classes. */
170
+ costClasses?: string[];
171
+ /** A name's own logo, set on the dashboard: an https address or one emoji.
172
+ * Drawn in place of the provider's mark. */
173
+ icon?: string;
174
+ }
175
+ /** How a picker lays out the app's providers. See `UseModelOptions.layout`. */
176
+ export type ModelLayout = "providers" | "models" | "choices" | "automatic";
177
+ /** One entry the app writes itself, naming a model it exposes. */
178
+ export interface ModelChoice {
179
+ /** Stable; the pick is stored as `choice:<id>`. */
180
+ id: string;
181
+ /** "Fast", "Smart". */
182
+ label: string;
183
+ /** The quiet clause beside it. */
184
+ note?: string;
185
+ /** The model it runs, as the provider names it: `gpt-5-mini`. */
186
+ model: string;
187
+ /** Which provider, when two expose it: `openai`, its dashboard name, or its
188
+ * id. Any provider when left out. */
189
+ provider?: string;
190
+ /** Or one of the person's own agents, `claude-code`, for one of its models. */
191
+ runtime?: string;
192
+ }
193
+ /** One of the app's own providers, as `/v1/model-options` lists it. */
194
+ export interface ModelProviderWire {
195
+ id: string;
196
+ provider: string | null;
197
+ name: string;
198
+ cost_class: "open-weights" | "frontier";
199
+ locked: "plan" | "allowance" | null;
200
+ models: {
201
+ model: string;
202
+ default?: true;
203
+ }[];
204
+ }
205
+ /** One of the app's own names for a model, set on Providers. */
206
+ export interface ModelNameWire {
207
+ alias: string;
208
+ label: string;
209
+ note: string | null;
210
+ /** Its logo, set on the dashboard: an https address or one emoji. */
211
+ icon?: string;
212
+ cost_classes: string[];
213
+ provider: string | null;
214
+ locked: "plan" | "allowance" | null;
215
+ default?: true;
216
+ }
217
+ export interface ModelOptionsWire {
218
+ route: {
219
+ allow: string[];
220
+ prefer: string[];
221
+ };
222
+ providers: ModelProviderWire[];
223
+ names: ModelNameWire[];
224
+ layout: "providers" | "models" | "choices" | "automatic";
225
+ /** Whether Fast and Best are offered, as designed on Connect. Absent from a
226
+ * gateway older than the setting, which offered them. */
227
+ sizes?: boolean;
228
+ /** Which of Automatic, Fast and Best each layout offers, for an app whose
229
+ * code picks another than the one designed. Absent from an older gateway:
230
+ * `sizes` stands in for both sizes, and Automatic is always there. */
231
+ rowsByLayout?: Partial<Record<"providers" | "models" | "choices" | "automatic", {
232
+ auto: boolean;
233
+ fast: boolean;
234
+ best: boolean;
235
+ own?: boolean;
236
+ search?: boolean;
237
+ }>>;
156
238
  }
157
239
  export interface ModelGroup {
158
240
  /** Null for the first group — Automatic and the sizes head nothing. */
@@ -209,7 +291,11 @@ export interface Plan {
209
291
  price: number;
210
292
  interval: string;
211
293
  unit: string;
294
+ /** Runs a month on AI the app pays for. Null is unlimited. */
212
295
  included_units: number | null;
296
+ /** Runs a month on the user's own AI, counted apart from `included_units`.
297
+ * Null is unlimited. Optional so an older server that omits it parses. */
298
+ own_included_units?: number | null;
213
299
  /** What happens past the allowance: `stop` or `meter`. The difference
214
300
  * between a wall and a bill, and not guessable from the price. */
215
301
  overage: string;
@@ -232,10 +318,21 @@ export interface Plan {
232
318
  ask_to_connect?: boolean;
233
319
  /** Whether it can be taken without a payment rail. */
234
320
  selectable: boolean;
321
+ /** What the app calls a run, plural: "tasks". Optional for older servers;
322
+ * absent reads "runs". */
323
+ task_noun?: string;
324
+ /** About how many runs a credit plan's allowance buys at this app's typical
325
+ * run cost: "About 265 tasks a month". Null when it cannot be said. */
326
+ estimated_tasks?: number | null;
327
+ /** A plan sold by conversation: "Talk to us" links here. */
328
+ contact_url?: string | null;
329
+ /** "Everything in" this plan id; its card lists that, then `features_own`. */
330
+ features_from?: string | null;
331
+ features_own?: PlanFeatureWire[] | null;
235
332
  /**
236
333
  * WHAT CONNECTING THEIR OWN COMPUTE EARNS THEM.
237
334
  *
238
- * `none` | `unlimited` | `included` | `credit`. The one dimension no other
335
+ * `none` | `included` | `credit`. The one dimension no other
239
336
  * pricing component has a column for, and the reason a free plan on this
240
337
  * product can be worth choosing.
241
338
  */
@@ -244,16 +341,49 @@ export interface Plan {
244
341
  connected_included_units: number | null;
245
342
  /** Dollars back per interval on a `credit` reward. Zero otherwise. */
246
343
  connected_credit: number;
344
+ /** What the vendor's app enforces, as the card lists it (INT-91). Optional
345
+ * so an older server that omits it parses. */
346
+ features?: PlanFeatureWire[];
247
347
  }
248
348
  export interface CurrentPlan {
249
349
  plan_id: string;
350
+ /** Spent against `included_units`. */
250
351
  units_used: number;
352
+ /** Spent against `own_included_units`. Optional for older servers. */
353
+ own_units_used?: number;
354
+ /**
355
+ * WHAT SOBA ENFORCES: both allowances, what is left, when they reset. The
356
+ * gateway stops a run here; your app never has to. Optional for older
357
+ * servers, null when the plan is no longer in the catalogue.
358
+ */
359
+ limits?: {
360
+ unit: string;
361
+ included_units: number | null;
362
+ units_used: number;
363
+ remaining: number | null;
364
+ own_included_units: number | null;
365
+ own_units_used: number;
366
+ own_remaining: number | null;
367
+ resets_at: string | null;
368
+ } | null;
369
+ /**
370
+ * WHAT YOUR APP ENFORCES (INT-91): feature id to value, as this person
371
+ * bought it. Soba stops nothing on these; read them and decide.
372
+ */
373
+ features?: Record<string, boolean | number | string | null>;
374
+ /** How many of each priced feature are in use, as your app last reported
375
+ * (`{ seats: 12 }`). Separate from `features`, where `seats` is the cap. */
376
+ quantities?: Record<string, number>;
251
377
  period_end: string | null;
252
378
  trial_ends_at: string | null;
379
+ /** A free plan past its time limit: runs are refused, and every feature
380
+ * reads as off, so stop what the plan carried too. Absent on older servers. */
381
+ ended?: boolean;
253
382
  }
254
383
  export interface ConnectApi {
255
384
  context(): Promise<ConnectContext>;
256
385
  machines(): Promise<Machine[]>;
386
+ modelOptions(): Promise<ModelOptionsWire | null>;
257
387
  plans(): Promise<{
258
388
  plans: Plan[];
259
389
  current: CurrentPlan | null;
@@ -347,3 +477,47 @@ export interface Message {
347
477
  role: "user" | "assistant";
348
478
  content: string;
349
479
  }
480
+ /** A plan feature: what the vendor's app enforces, never Soba (INT-91). */
481
+ export interface PlanFeatureWire {
482
+ id: string;
483
+ name: string;
484
+ type: "boolean" | "number" | "text";
485
+ /** `true`/`false`, a whole number, or null for unlimited. */
486
+ value: boolean | number | string | null;
487
+ unit?: string;
488
+ /** "month": the count starts over each billing period. Absent: never. */
489
+ resets?: "month";
490
+ /** Seats: a price per unit, in micros, past `included` units. */
491
+ price?: {
492
+ amountMicros: number;
493
+ included: number;
494
+ };
495
+ }
496
+ /**
497
+ * WHY A FEATURE IS NOT AVAILABLE, in the words an app branches on — the same
498
+ * four `soba.users.check` answers with. Declared here, not imported from
499
+ * connect-ui, because connect-ui is private and bundled: a published type
500
+ * that pointed at it would not resolve in an app.
501
+ */
502
+ export type FeatureDenial = "no_plan" | "plan_ended" | "not_included" | "limit_reached";
503
+ /** What the browser's check says about one feature. */
504
+ export interface FeatureCheck {
505
+ feature: string;
506
+ allowed: boolean;
507
+ /** Null when allowed. */
508
+ reason: FeatureDenial | null;
509
+ /** The plan's number for it: a cap, or null for unlimited. Null for a switch. */
510
+ limit: number | null;
511
+ /** How many are in use now. Zero for a switch. */
512
+ used: number;
513
+ /** What is left under the cap, never below zero. Null when unlimited or a switch. */
514
+ remaining: number | null;
515
+ unlimited: boolean;
516
+ }
517
+ /** One line of a plan's features on its card, and whether the plan has it —
518
+ * a "no" is drawn crossed out. Declared here for the reason `FeatureCheck`
519
+ * is: connect-ui is bundled, and a published type cannot name it. */
520
+ export interface FeatureItem {
521
+ text: string;
522
+ included: boolean;
523
+ }
@@ -14,9 +14,9 @@ import type { CurrentPlan, Plan } from "./types.js";
14
14
  * - `connected_reward: "included"` RAISES the ceiling to
15
15
  * `connected_included_units` while a machine is attached, awake or not,
16
16
  * because a larger allowance is a promise about the month.
17
- * - `connected_reward: "unlimited"` raises nothing. It takes the runs their own
18
- * AI SERVES out of the count, so the ceiling stays the plan's and the number
19
- * simply stops rising for those runs.
17
+ * - Runs their own AI SERVES never count toward `included_units`: they have
18
+ * their own allowance, `own_included_units` (INT-20). So the number here
19
+ * only rises for runs the app pays for, whatever the reward.
20
20
  * - `credit` touches neither: it settles at renewal.
21
21
  *
22
22
  * Those last three mirror `allowanceFor` in `@soba-platform/core`, which is the
@@ -94,5 +94,11 @@ export interface CurrentPlanView {
94
94
  /** One sentence, already chosen from the state above. The thing a billing
95
95
  * page has to get right and the reason this hook exists. */
96
96
  summary: string;
97
+ /**
98
+ * WHAT YOUR APP ENFORCES (INT-91): feature id to value, as this person
99
+ * bought it — `features.projects`, `features.export`. Soba stops nothing on
100
+ * these; everything above is what it does stop. Empty with no plan.
101
+ */
102
+ features: Record<string, boolean | number | string | null>;
97
103
  }
98
104
  export declare function useCurrentPlan(): CurrentPlanView;
@@ -0,0 +1,27 @@
1
+ import type { FeatureCheck } from "./types.js";
2
+ /**
3
+ * MAY THIS PERSON DO THIS — for drawing the button, not for trusting it.
4
+ *
5
+ * The same rule the server's `soba.users.check` runs, applied in the browser
6
+ * to the plan the provider already holds: the features they bought, how many
7
+ * are in use, and whether a free plan's time is up. So a "New workspace"
8
+ * button can be drawn disabled, with the reason, before anyone presses it.
9
+ *
10
+ * It decides what to SHOW. What actually happens is still the server's call:
11
+ * create the workspace behind `soba.users.check(user, "workspaces", { use: 1 })`
12
+ * and the count and the limit are tested together. After a server-side
13
+ * `check` with `use` or a `track`, call `refresh()` for the new count.
14
+ */
15
+ export interface FeatureView extends FeatureCheck {
16
+ /** The feature's name on the plan ("Workspaces"), or its id. */
17
+ name: string;
18
+ /** What it counts ("workspaces"), for "3 of 3 workspaces". */
19
+ unit: string | null;
20
+ loading: boolean;
21
+ error: Error | null;
22
+ /** Reload the plan and the counts. */
23
+ refresh(): Promise<void>;
24
+ }
25
+ export declare function useFeature(feature: string, options?: {
26
+ use?: number;
27
+ }): FeatureView;