@colixsystems/widget-sdk 0.154.0 → 0.156.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/README.md +21 -4
- package/dist/container-width.js +27 -0
- package/dist/contract.cjs +39 -3
- package/dist/contract.js +39 -3
- package/dist/hooks.js +29 -3
- package/dist/index.d.ts +19 -0
- package/dist/index.js +6 -1
- package/dist/index.native.js +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
31
31
|
| **CORE** | `useFill()` | `boolean` | `ctx.fill` — no scope. `true` when the host sized this widget to fill its page-grid tile's reserved height (containers + media fill by default; the author can override per tile). Media-style widgets switch to a `flex: 1` / `height: "100%"` layout; others ignore it. Defaults `false`. |
|
|
32
32
|
| **CORE** | `useContainerWidth()` | `[width, onLayout]` | No context slice, no scope. Measures the width the widget's OWN box has, so it can lay itself out for the space it is in rather than for the screen. Spread the handler onto your outermost primitive. Use it for any widget with a wide form and a narrow one (a table, a toolbar, a row of tiles) — never switch on the device or window width, because a widget in a one-of-three grid cell on desktop has phone-width room and a widget filling a phone page does not. Width is 0 until the first layout: render the WIDE form then. One implementation covers web (react-native-web) and the native export. |
|
|
33
33
|
| **CORE** | `isNarrowWidth(width)` | `boolean` | No context slice, no scope. True when a MEASURED width is below `NARROW_WIDTH_PX` (480) — the one threshold every widget switches at, so a page reflows together rather than raggedly. An unmeasured width (0) is NOT narrow, so nothing flashes through the narrow form on first paint. |
|
|
34
|
+
| **CORE** | `widthTier(width)` | `"narrow" \| "medium" \| "wide"` | No context slice, no scope. Which of THREE layouts a measured width calls for: narrow below `NARROW_WIDTH_PX` (480), wide at or above `WIDE_WIDTH_PX` (960), medium between. `widthTier(w) === "narrow"` exactly when `isNarrowWidth(w)`; an unmeasured width (0) is "wide". |
|
|
34
35
|
| **CORE** | `useSectionEmpty(isEmpty)` | `void` | `ctx.section.reportEmpty` — no scope. Declares that the widget has NO content to show, so the host drops its layout slot instead of reserving space (and its parent's `gap`) for it. Returning `null` is not enough: the host wraps every widget in an entrance element, so a widget rendering nothing still leaves an empty box the parent stack gaps around. For a CONDITIONALLY ABSENT section (a per-record child collection with no rows for this record), never to suppress a genuine empty state. Stays mounted while collapsed, so passing `false` brings it back. Authoring surfaces never collapse. No-op on a host that doesn't implement it. |
|
|
35
36
|
| **CORE** | `useScreenTitle(title)` | `void` | `ctx.screen.reportTitle` — no scope. Names the screen the widget is on, so the host paints that name in the app header beside the back control instead of the app brand. The page's own name is the default; only the widget knows the screen is really "Order #1423" rather than "Orders". The MOST RECENT call wins — with several widgets on one screen the last to report owns the title, and on unmount (or an empty title) the host falls back to the most recent surviving report and finally to the page name. Only a screen showing the back control paints a title; a root page keeps the brand. No-op on a host that doesn't implement it. |
|
|
36
37
|
| **CORE** | `useRefresh(handler)` | `void` | `ctx.refresh.subscribe` — no scope. Subscribes the handler to the page-level refresh tick (pull-to-refresh on mobile). Handler may return a Promise — the host waits for `allSettled` before clearing the spinner. The three datastore hooks auto-subscribe their own `refetch`; widgets only call this directly to re-run non-datastore work. No-op on a host that doesn't implement refresh. |
|
|
@@ -72,7 +73,23 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
72
73
|
|
|
73
74
|
## Status
|
|
74
75
|
|
|
75
|
-
`v0.
|
|
76
|
+
`v0.156.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
77
|
+
|
|
78
|
+
### What's new in 0.156.0 (contract 1.122.0)
|
|
79
|
+
|
|
80
|
+
**A third layout tier: `widthTier(width)` and `WIDE_WIDTH_PX` (sc-8508).** `isNarrowWidth` splits widths into narrow and everything else, so a widget alone on a desktop page rendered the same layout it uses in a half-width cell and left most of a 1200px box empty. `widthTier` returns `"narrow"`, `"medium"` or `"wide"` — lay a form out in one column, then paired fields, then two or three columns; a list as stacked cards, then one-line cards, then rows with real columns.
|
|
81
|
+
|
|
82
|
+
Additive: `isNarrowWidth` and `NARROW_WIDTH_PX` are unchanged, and `widthTier(w) === "narrow"` exactly when `isNarrowWidth(w)` is true.
|
|
83
|
+
|
|
84
|
+
### What's new in 0.155.0 (contract 1.121.0)
|
|
85
|
+
|
|
86
|
+
**`useAgent()` returns `cancel()` — and two error codes that tell the truth about a slow turn (sc-8519).** A turn can legitimately run for minutes; the client used to give up after 10 seconds and report it as `NETWORK`, so a widget showed "you appear to be offline" to a user whose connection was fine, while the server finished the turn and the workspace paid for a reply nobody saw.
|
|
87
|
+
|
|
88
|
+
- **`cancel()`** stops WAITING for the turn in flight. The abort never reaches the server, which finishes and bills the turn either way — so label the control **stop**, not *cancel*, and never imply the work was called off.
|
|
89
|
+
- **`TIMEOUT`** — the client stopped waiting. Still running, still billed: say it is taking longer than usual, never that it failed.
|
|
90
|
+
- **`CANCELLED`** — the user pressed your stop control. Render no error at all.
|
|
91
|
+
|
|
92
|
+
Additive: `send` is unchanged for callers who ignore the new return key, and a widget that never calls `cancel()` behaves exactly as before.
|
|
76
93
|
|
|
77
94
|
### What's new in 0.154.0 (contract 1.120.0)
|
|
78
95
|
|
|
@@ -1736,10 +1753,10 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
1736
1753
|
- **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
|
|
1737
1754
|
- **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
|
|
1738
1755
|
- **Answer the touch.** Every tappable card, row and list entry lifts while the pointer is over it (web) or it is pressed (touch). One declaration does both: give the Pressable a style FUNCTION and spread `pressableLift` — `<Pressable onPress={open} style={(state) => [styles.card, ...pressableLift(state)]}>`. The lift is a -2px nudge plus one elevation step from `theme.interaction`, with the web transition built in. Never hand-write hover logic or your own pressed shadows, and never fake feedback with `opacity` — a dimmed surface reads as disabling itself.
|
|
1739
|
-
- **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `onLayout=
|
|
1740
|
-
- **Compose forms — pair fields into rows, don't stack one per row.**
|
|
1756
|
+
- **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `const [width, onLayout] = useContainerWidth();`, spreading `onLayout` on an unpadded root `View` (a React Native callback, so it behaves identically on both hosts). A width of `0` means "not yet measured" and the SDK helpers answer it with the wide form, so do not special-case it — except a cell whose definite size is computed from the width (a calendar day, a chart), which renders nothing size-dependent until the width arrives: a widget gets no declared breakpoint prop, but it can always measure. **Every form, list, table and filter bar lays out in three tiers** (sc-8508): `const [width, onLayout] = useContainerWidth();` then branch on `widthTier(width)` — a form is one column narrow, paired short fields at medium, a 2–3 column grid capped near 1000px wide; a list is stacked cards, one-line cards, then real columns under a header; a filter bar is one toolbar row (search, a dropdown per facet, sort) with the active filters as removable chips beneath, and a single "Filters" button opening a panel when narrow. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces in the wide tier** (`flexDirection: widthTier(width) === 'wide' ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead. **A metric / stat-tile row FILLS ITS ROW** — when you know the count, balance the lines (`const lines = Math.ceil(count / max); const base = Math.floor(count / lines); const extra = count % lines; const rows = Array.from({ length: lines }, (_, i) => tiles.slice(i * base + Math.min(i, extra), (i + 1) * base + Math.min(i + 1, extra)));`, with `max` 4 / 3 / 2 by tier, each tile `{ flex: 1, minWidth: 0 }`), and when the count comes from data wrap it (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }`, never a `maxWidth` — capped tiles cluster to one side), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
|
|
1757
|
+
- **Compose forms — pair fields into rows, don't stack one per row.** Lay the form out in three tiers and cap it near 1000px, centred with the three declarations above — never a 560–720px column alone in a wide box, which leaves a form that IS the page as a narrow strip in empty space. Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. When a field carries an icon beside it (a search glyph, a clear button), the border belongs on the WRAPPER row and the `TextInput` inside it goes borderless and transparent with `flex: 1, minWidth: 0` (the `minWidth: 0` stops a long value pushing the border past its container) — React Native has no `:focus-within`, so drive the wrapper's `borderColor` between `colors.border` and `colors.primary` from the input's own `onFocus` / `onBlur`. A border left on the input rings only its own `<input>` box on web, leaving the icon outside the ring; never absolutely-position the icon over the field to work around it.
|
|
1741
1758
|
- **Respond to touch.** Give every `Pressable` the lift via the function-style `style={(state) => [base, ...pressableLift(state)]}` — see "Answer the touch" above. Never dim with `opacity`, which reads as the surface disabling itself.
|
|
1742
|
-
- **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView`
|
|
1759
|
+
- **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView` when `widthTier(width) === "narrow"`, with the grip claiming the gesture (`onStartShouldSetPanResponder`, `onPanResponderTerminationRequest: () => false`) and carrying `touchAction: 'none'` on web so a finger drag moves the card instead of scrolling the page. And because `useRef(PanResponder.create(...))` runs ONCE, everything its callbacks read — the `useCanWrite` flag included, which starts `false` — goes through a ref refreshed every render (`latest.current.canDrag`); the publish gate `widget.staleResponder` rejects a value read directly.
|
|
1743
1760
|
- **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
|
|
1744
1761
|
- **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useAsset()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead. The frame is your decision, never the picture's: size it for the role (a 40–56 square avatar, a 72–96 square row thumbnail, a `16 / 9` card cover, a 160–240 tall band) and let `resizeMode="cover"` crop the photo into it — photos arrive at every size and ratio, so one left to its own proportions breaks the layout. Keep `contain` for art whose whole subject must stay visible (a logo, a diagram), inside a fixed frame.
|
|
1745
1762
|
- **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
|
package/dist/container-width.js
CHANGED
|
@@ -26,3 +26,30 @@ export const NARROW_WIDTH_PX = 480;
|
|
|
26
26
|
export function isNarrowWidth(width) {
|
|
27
27
|
return typeof width === "number" && width > 0 && width < NARROW_WIDTH_PX;
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* sc-8508 — the width above which a widget has room for its WIDE form: a form
|
|
32
|
+
* in two or three columns, a list whose rows carry real columns, a filter bar
|
|
33
|
+
* on one toolbar row. Twice the narrow threshold, which is the three-column
|
|
34
|
+
* break the Developer guide already showed as `NARROW_WIDTH_PX * 2`.
|
|
35
|
+
*/
|
|
36
|
+
export const WIDE_WIDTH_PX = 960;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Which of the three layouts a MEASURED width calls for: "narrow" below
|
|
40
|
+
* `NARROW_WIDTH_PX`, "wide" at or above `WIDE_WIDTH_PX`, "medium" between.
|
|
41
|
+
*
|
|
42
|
+
* Two tiers were not enough. With only "narrow" and "not narrow", a widget
|
|
43
|
+
* alone on a desktop page rendered the layout it would use in a half-width
|
|
44
|
+
* cell and left the rest of its box empty.
|
|
45
|
+
*
|
|
46
|
+
* An unmeasured width (0, before the first `onLayout`) is "wide", for the same
|
|
47
|
+
* reason `isNarrowWidth(0)` is false: the wide rendering is the historical one,
|
|
48
|
+
* and a widget using both helpers must never get two different answers.
|
|
49
|
+
*/
|
|
50
|
+
export function widthTier(width) {
|
|
51
|
+
if (typeof width !== "number" || !(width > 0)) return "wide";
|
|
52
|
+
if (width < NARROW_WIDTH_PX) return "narrow";
|
|
53
|
+
if (width < WIDE_WIDTH_PX) return "medium";
|
|
54
|
+
return "wide";
|
|
55
|
+
}
|
package/dist/contract.cjs
CHANGED
|
@@ -771,7 +771,7 @@ const HOOKS = [
|
|
|
771
771
|
"callback, so ONE implementation serves the web Player (through " +
|
|
772
772
|
"react-native-web) and the native export. Before the first layout the " +
|
|
773
773
|
"width is 0: treat that as \"not yet measured\" and render the wide " +
|
|
774
|
-
"form. Pair it with `isNarrowWidth(width)` so every widget switches at " +
|
|
774
|
+
"form. Pair it with `widthTier(width)` (or `isNarrowWidth(width)`) so every widget switches at " +
|
|
775
775
|
"the same threshold. Takes no host context, so it is safe everywhere " +
|
|
776
776
|
"including the Studio canvas.",
|
|
777
777
|
returnShape: {
|
|
@@ -796,6 +796,25 @@ const HOOKS = [
|
|
|
796
796
|
requiredContextSlice: [],
|
|
797
797
|
scopes: null,
|
|
798
798
|
},
|
|
799
|
+
{
|
|
800
|
+
name: "widthTier",
|
|
801
|
+
signature: "widthTier(width)",
|
|
802
|
+
description:
|
|
803
|
+
"Which of the THREE layouts a MEASURED width calls for: \"narrow\" " +
|
|
804
|
+
"below `NARROW_WIDTH_PX` (480), \"wide\" at or above `WIDE_WIDTH_PX` " +
|
|
805
|
+
"(960), \"medium\" between. A widget alone on a desktop page has far " +
|
|
806
|
+
"more room than one in a half-width cell, and two tiers treated both " +
|
|
807
|
+
"the same, so the lone widget left most of its box empty. Branch on " +
|
|
808
|
+
"this for every list, form, table and filter bar: a form goes one " +
|
|
809
|
+
"column, then paired fields, then two or three columns; a list goes " +
|
|
810
|
+
"stacked cards, then one-line cards, then rows with real columns. An " +
|
|
811
|
+
"unmeasured width (0) is \"wide\", matching `isNarrowWidth(0)`.",
|
|
812
|
+
returnShape: {
|
|
813
|
+
"(returns)": "\"narrow\" | \"medium\" | \"wide\"",
|
|
814
|
+
},
|
|
815
|
+
requiredContextSlice: [],
|
|
816
|
+
scopes: null,
|
|
817
|
+
},
|
|
799
818
|
{
|
|
800
819
|
name: "useSectionEmpty",
|
|
801
820
|
signature: "useSectionEmpty(isEmpty)",
|
|
@@ -1308,7 +1327,12 @@ const HOOKS = [
|
|
|
1308
1327
|
"AI_QUOTA_EXCEEDED (429) is the workspace's credit ceiling and will " +
|
|
1309
1328
|
"NOT clear on a retry — never offer 'try again' for it — where " +
|
|
1310
1329
|
"RATE_LIMITED (429) will; AGENT_DISABLED (409) means the author turned " +
|
|
1311
|
-
"it off, so hide the affordance rather than retrying
|
|
1330
|
+
"it off, so hide the affordance rather than retrying; TIMEOUT means the " +
|
|
1331
|
+
"client stopped waiting and the turn is STILL running and billed, so " +
|
|
1332
|
+
"say it is taking longer, never that it failed or that they are " +
|
|
1333
|
+
"offline; CANCELLED is the user pressing your stop control, so render " +
|
|
1334
|
+
"no error at all. Call cancel() to stop waiting -- it ends the WAIT, " +
|
|
1335
|
+
"not the turn. NEVER surface a " +
|
|
1312
1336
|
"raw error or the workspace's billing state to an app user. " +
|
|
1313
1337
|
"Reads ctx.agents.",
|
|
1314
1338
|
returnShape: {
|
|
@@ -4232,6 +4256,12 @@ const CONTRACT = deepFreeze({
|
|
|
4232
4256
|
// and the inherited colours are hex-guarded on the way out as well as in.
|
|
4233
4257
|
// Every colour still falls back to the sidebar's: an untouched workspace is
|
|
4234
4258
|
// unchanged.
|
|
4259
|
+
// 1.121.0: additive (sc-8519) — `useAgent()` returns `cancel()`, which
|
|
4260
|
+
// stops WAITING for the turn in flight. The abort is client-side: the
|
|
4261
|
+
// server finishes the turn and the workspace is billed, so label the
|
|
4262
|
+
// control 'stop', never 'cancel'. Two new AgentError codes ride with it —
|
|
4263
|
+
// TIMEOUT (the client stopped waiting; still running, still billed) and
|
|
4264
|
+
// CANCELLED (the user pressed stop; render no error at all).
|
|
4235
4265
|
// 1.120.0: additive (sc-8332) — an assistant `AgentMessage` may carry
|
|
4236
4266
|
// `activity`, a compact record of what the agent DID with the user's data
|
|
4237
4267
|
// (the table it read; the table and columns it CHANGED). Absent on a
|
|
@@ -4298,7 +4328,13 @@ const CONTRACT = deepFreeze({
|
|
|
4298
4328
|
// untouched and renders byte-identically, and an unresolvable binding
|
|
4299
4329
|
// fails the existing hex guard, dropping the wash rather than painting it
|
|
4300
4330
|
// blank.
|
|
4301
|
-
|
|
4331
|
+
// 1.122.0: additive (sc-8508) — `widthTier(width)` + `WIDE_WIDTH_PX`, a
|
|
4332
|
+
// third layout tier. `isNarrowWidth` split widths into "phone" and
|
|
4333
|
+
// "everything else", so a widget alone on a desktop page rendered the
|
|
4334
|
+
// layout it would use in a half-width cell. Additive: `isNarrowWidth` and
|
|
4335
|
+
// `NARROW_WIDTH_PX` are unchanged, and `widthTier(w) === "narrow"` exactly
|
|
4336
|
+
// when `isNarrowWidth(w)` is true.
|
|
4337
|
+
version: "1.122.0",
|
|
4302
4338
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4303
4339
|
hooks: HOOKS,
|
|
4304
4340
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -771,7 +771,7 @@ const HOOKS = [
|
|
|
771
771
|
"callback, so ONE implementation serves the web Player (through " +
|
|
772
772
|
"react-native-web) and the native export. Before the first layout the " +
|
|
773
773
|
"width is 0: treat that as \"not yet measured\" and render the wide " +
|
|
774
|
-
"form. Pair it with `isNarrowWidth(width)` so every widget switches at " +
|
|
774
|
+
"form. Pair it with `widthTier(width)` (or `isNarrowWidth(width)`) so every widget switches at " +
|
|
775
775
|
"the same threshold. Takes no host context, so it is safe everywhere " +
|
|
776
776
|
"including the Studio canvas.",
|
|
777
777
|
returnShape: {
|
|
@@ -796,6 +796,25 @@ const HOOKS = [
|
|
|
796
796
|
requiredContextSlice: [],
|
|
797
797
|
scopes: null,
|
|
798
798
|
},
|
|
799
|
+
{
|
|
800
|
+
name: "widthTier",
|
|
801
|
+
signature: "widthTier(width)",
|
|
802
|
+
description:
|
|
803
|
+
"Which of the THREE layouts a MEASURED width calls for: \"narrow\" " +
|
|
804
|
+
"below `NARROW_WIDTH_PX` (480), \"wide\" at or above `WIDE_WIDTH_PX` " +
|
|
805
|
+
"(960), \"medium\" between. A widget alone on a desktop page has far " +
|
|
806
|
+
"more room than one in a half-width cell, and two tiers treated both " +
|
|
807
|
+
"the same, so the lone widget left most of its box empty. Branch on " +
|
|
808
|
+
"this for every list, form, table and filter bar: a form goes one " +
|
|
809
|
+
"column, then paired fields, then two or three columns; a list goes " +
|
|
810
|
+
"stacked cards, then one-line cards, then rows with real columns. An " +
|
|
811
|
+
"unmeasured width (0) is \"wide\", matching `isNarrowWidth(0)`.",
|
|
812
|
+
returnShape: {
|
|
813
|
+
"(returns)": "\"narrow\" | \"medium\" | \"wide\"",
|
|
814
|
+
},
|
|
815
|
+
requiredContextSlice: [],
|
|
816
|
+
scopes: null,
|
|
817
|
+
},
|
|
799
818
|
{
|
|
800
819
|
name: "useSectionEmpty",
|
|
801
820
|
signature: "useSectionEmpty(isEmpty)",
|
|
@@ -1308,7 +1327,12 @@ const HOOKS = [
|
|
|
1308
1327
|
"AI_QUOTA_EXCEEDED (429) is the workspace's credit ceiling and will " +
|
|
1309
1328
|
"NOT clear on a retry — never offer 'try again' for it — where " +
|
|
1310
1329
|
"RATE_LIMITED (429) will; AGENT_DISABLED (409) means the author turned " +
|
|
1311
|
-
"it off, so hide the affordance rather than retrying
|
|
1330
|
+
"it off, so hide the affordance rather than retrying; TIMEOUT means the " +
|
|
1331
|
+
"client stopped waiting and the turn is STILL running and billed, so " +
|
|
1332
|
+
"say it is taking longer, never that it failed or that they are " +
|
|
1333
|
+
"offline; CANCELLED is the user pressing your stop control, so render " +
|
|
1334
|
+
"no error at all. Call cancel() to stop waiting -- it ends the WAIT, " +
|
|
1335
|
+
"not the turn. NEVER surface a " +
|
|
1312
1336
|
"raw error or the workspace's billing state to an app user. " +
|
|
1313
1337
|
"Reads ctx.agents.",
|
|
1314
1338
|
returnShape: {
|
|
@@ -4228,6 +4252,12 @@ const CONTRACT = deepFreeze({
|
|
|
4228
4252
|
// and the inherited colours are hex-guarded on the way out as well as in.
|
|
4229
4253
|
// Every colour still falls back to the sidebar's: an untouched workspace is
|
|
4230
4254
|
// unchanged.
|
|
4255
|
+
// 1.121.0: additive (sc-8519) — `useAgent()` returns `cancel()`, which
|
|
4256
|
+
// stops WAITING for the turn in flight. The abort is client-side: the
|
|
4257
|
+
// server finishes the turn and the workspace is billed, so label the
|
|
4258
|
+
// control 'stop', never 'cancel'. Two new AgentError codes ride with it —
|
|
4259
|
+
// TIMEOUT (the client stopped waiting; still running, still billed) and
|
|
4260
|
+
// CANCELLED (the user pressed stop; render no error at all).
|
|
4231
4261
|
// 1.120.0: additive (sc-8332) — an assistant `AgentMessage` may carry
|
|
4232
4262
|
// `activity`, a compact record of what the agent DID with the user's data
|
|
4233
4263
|
// (the table it read; the table and columns it CHANGED). Absent on a
|
|
@@ -4294,7 +4324,13 @@ const CONTRACT = deepFreeze({
|
|
|
4294
4324
|
// untouched and renders byte-identically, and an unresolvable binding
|
|
4295
4325
|
// fails the existing hex guard, dropping the wash rather than painting it
|
|
4296
4326
|
// blank.
|
|
4297
|
-
|
|
4327
|
+
// 1.122.0: additive (sc-8508) — `widthTier(width)` + `WIDE_WIDTH_PX`, a
|
|
4328
|
+
// third layout tier. `isNarrowWidth` split widths into "phone" and
|
|
4329
|
+
// "everything else", so a widget alone on a desktop page rendered the
|
|
4330
|
+
// layout it would use in a half-width cell. Additive: `isNarrowWidth` and
|
|
4331
|
+
// `NARROW_WIDTH_PX` are unchanged, and `widthTier(w) === "narrow"` exactly
|
|
4332
|
+
// when `isNarrowWidth(w)` is true.
|
|
4333
|
+
version: "1.122.0",
|
|
4298
4334
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4299
4335
|
hooks: HOOKS,
|
|
4300
4336
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -2713,7 +2713,15 @@ export function useInterpretDraft(tableId) {
|
|
|
2713
2713
|
* - "AI_UNAVAILABLE" — 503 (no AI provider configured)
|
|
2714
2714
|
* - "AI_GENERATION_FAILED" — 502 (the provider did not answer)
|
|
2715
2715
|
* - "AUTH_REQUIRED" — 401 (no signed-in app user)
|
|
2716
|
-
* - "
|
|
2716
|
+
* - "NETWORK" — the request never reached the server
|
|
2717
|
+
* - "TIMEOUT" — the client stopped waiting. NOT a failed
|
|
2718
|
+
* turn: the server finishes it and the
|
|
2719
|
+
* workspace is billed, so say "still
|
|
2720
|
+
* working", never "failed"
|
|
2721
|
+
* - "CANCELLED" — `cancel()` was called. The user chose it,
|
|
2722
|
+
* so render no error at all (the turn is
|
|
2723
|
+
* still billed — never imply it stopped)
|
|
2724
|
+
* - "INTERNAL" — anything else (a 5xx carrying no code)
|
|
2717
2725
|
*/
|
|
2718
2726
|
export class AgentError extends Error {
|
|
2719
2727
|
constructor(code, message, opts) {
|
|
@@ -2816,12 +2824,21 @@ export function useAgent(agentRef) {
|
|
|
2816
2824
|
pendingResetRef.current = true;
|
|
2817
2825
|
}
|
|
2818
2826
|
|
|
2827
|
+
const abortRef = useRef(null);
|
|
2828
|
+
|
|
2819
2829
|
const reset = useCallback(() => {
|
|
2820
2830
|
conversationRef.current = null;
|
|
2821
2831
|
setMessages([]);
|
|
2822
2832
|
setError(null);
|
|
2823
2833
|
}, []);
|
|
2824
2834
|
|
|
2835
|
+
// Ends the WAIT, not the turn: the abort never reaches the server, which
|
|
2836
|
+
// finishes and bills it either way. Never imply the work was stopped.
|
|
2837
|
+
const cancel = useCallback(() => {
|
|
2838
|
+
const controller = abortRef.current;
|
|
2839
|
+
if (controller) controller.abort();
|
|
2840
|
+
}, []);
|
|
2841
|
+
|
|
2825
2842
|
const send = useCallback(async (text) => {
|
|
2826
2843
|
const client = clientRef.current;
|
|
2827
2844
|
const agentId = agentIdRef.current;
|
|
@@ -2855,7 +2872,13 @@ export function useAgent(agentRef) {
|
|
|
2855
2872
|
conversationRef.current = null;
|
|
2856
2873
|
throw err;
|
|
2857
2874
|
}
|
|
2858
|
-
const
|
|
2875
|
+
const controller =
|
|
2876
|
+
typeof AbortController === "function" ? new AbortController() : null;
|
|
2877
|
+
abortRef.current = controller;
|
|
2878
|
+
const turn = await thread
|
|
2879
|
+
.messages(conversationId)
|
|
2880
|
+
.send({ content }, controller ? { signal: controller.signal } : undefined);
|
|
2881
|
+
abortRef.current = null;
|
|
2859
2882
|
// A rebind during the round trip makes this the PREVIOUS agent's reply;
|
|
2860
2883
|
// writing it now would render it under the new one.
|
|
2861
2884
|
if (agentIdRef.current !== agentId) {
|
|
@@ -2874,8 +2897,10 @@ export function useAgent(agentRef) {
|
|
|
2874
2897
|
// A stale thread is recoverable on the next send rather than permanently
|
|
2875
2898
|
// broken: drop it so the widget opens a fresh one.
|
|
2876
2899
|
if (e.code === "CONVERSATION_NOT_FOUND") conversationRef.current = null;
|
|
2900
|
+
abortRef.current = null;
|
|
2877
2901
|
// Same rebind guard: a new agent must not inherit the old one's error.
|
|
2878
|
-
|
|
2902
|
+
// A cancel is the user's own choice, so it is not a failure to report.
|
|
2903
|
+
if (e.code !== "CANCELLED" && agentIdRef.current === agentId) setError(e);
|
|
2879
2904
|
setSending(false);
|
|
2880
2905
|
throw e;
|
|
2881
2906
|
}
|
|
@@ -2897,6 +2922,7 @@ export function useAgent(agentRef) {
|
|
|
2897
2922
|
|
|
2898
2923
|
return {
|
|
2899
2924
|
send,
|
|
2925
|
+
cancel,
|
|
2900
2926
|
reset,
|
|
2901
2927
|
messages: visibleMessages,
|
|
2902
2928
|
sending,
|
package/dist/index.d.ts
CHANGED
|
@@ -1239,6 +1239,12 @@ export interface AgentActivity {
|
|
|
1239
1239
|
export interface AgentApi {
|
|
1240
1240
|
/** Imperative — call from an event handler. The first send opens the thread. */
|
|
1241
1241
|
send(text: string): Promise<AgentMessage | null>;
|
|
1242
|
+
/**
|
|
1243
|
+
* Stop WAITING for the turn in flight. The abort never reaches the server,
|
|
1244
|
+
* which finishes and bills it either way — label the control "stop", not
|
|
1245
|
+
* "cancel", and show no error when the user presses it.
|
|
1246
|
+
*/
|
|
1247
|
+
cancel(): void;
|
|
1242
1248
|
/** Drop the open thread and clear the transcript this widget has seen. */
|
|
1243
1249
|
reset(): void;
|
|
1244
1250
|
/** Oldest first. The SERVER owns the real transcript. */
|
|
@@ -1557,6 +1563,16 @@ export const NARROW_WIDTH_PX: number;
|
|
|
1557
1563
|
*/
|
|
1558
1564
|
export function isNarrowWidth(width: number): boolean;
|
|
1559
1565
|
|
|
1566
|
+
/** The width at or above which a widget has room for its wide form (960). */
|
|
1567
|
+
export const WIDE_WIDTH_PX: number;
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* Which of the three layouts a MEASURED width calls for: `"narrow"` below
|
|
1571
|
+
* `NARROW_WIDTH_PX`, `"wide"` at or above `WIDE_WIDTH_PX`, `"medium"` between.
|
|
1572
|
+
* An unmeasured width (0) is `"wide"`, matching `isNarrowWidth(0) === false`.
|
|
1573
|
+
*/
|
|
1574
|
+
export function widthTier(width: number): "narrow" | "medium" | "wide";
|
|
1575
|
+
|
|
1560
1576
|
/** Pass-through options for `useGeolocation().getCurrentPosition(...)`. */
|
|
1561
1577
|
export interface GeolocationOptions {
|
|
1562
1578
|
enableHighAccuracy?: boolean;
|
|
@@ -1997,6 +2013,9 @@ export class AgentError extends Error {
|
|
|
1997
2013
|
| "AI_UNAVAILABLE"
|
|
1998
2014
|
| "AI_GENERATION_FAILED"
|
|
1999
2015
|
| "AUTH_REQUIRED"
|
|
2016
|
+
| "NETWORK"
|
|
2017
|
+
| "TIMEOUT"
|
|
2018
|
+
| "CANCELLED"
|
|
2000
2019
|
| "INTERNAL"
|
|
2001
2020
|
| (string & {});
|
|
2002
2021
|
constructor(code: string, message: string, opts?: { cause?: unknown });
|
package/dist/index.js
CHANGED
|
@@ -91,7 +91,12 @@ export {
|
|
|
91
91
|
BarcodeError,
|
|
92
92
|
WidgetTree,
|
|
93
93
|
} from "./hooks.js";
|
|
94
|
-
export {
|
|
94
|
+
export {
|
|
95
|
+
isNarrowWidth,
|
|
96
|
+
NARROW_WIDTH_PX,
|
|
97
|
+
widthTier,
|
|
98
|
+
WIDE_WIDTH_PX,
|
|
99
|
+
} from "./container-width.js";
|
|
95
100
|
// REQ-WSDK-PLATFORM §6 — Tier A hooks. Each ships in a per-platform file
|
|
96
101
|
// (./clipboard.js / .native.js, ./toast.js / .native.js); index.js picks
|
|
97
102
|
// the web variant and index.native.js picks the native variant.
|
package/dist/index.native.js
CHANGED
|
@@ -91,7 +91,12 @@ export {
|
|
|
91
91
|
BarcodeError,
|
|
92
92
|
WidgetTree,
|
|
93
93
|
} from "./hooks.js";
|
|
94
|
-
export {
|
|
94
|
+
export {
|
|
95
|
+
isNarrowWidth,
|
|
96
|
+
NARROW_WIDTH_PX,
|
|
97
|
+
widthTier,
|
|
98
|
+
WIDE_WIDTH_PX,
|
|
99
|
+
} from "./container-width.js";
|
|
95
100
|
// REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
|
|
96
101
|
export { useClipboard, ClipboardError } from "./clipboard.native.js";
|
|
97
102
|
export { useToast } from "./toast.native.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.156.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|