@colixsystems/widget-sdk 0.189.0 → 0.190.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 CHANGED
@@ -53,6 +53,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
53
53
  | **AGENTS** | `useAgent(agentRef)` | `{ send, cancel, reset, messages, sending, resuming, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). Sending is IMPERATIVE: call `send(text)` from an event handler, never on mount — the first send opens the conversation, so a widget nobody types into costs nothing. On mount the hook DOES reopen the caller's most recent thread with this agent, so a refresh or a navigation keeps the conversation instead of starting a blank one; that resume only READS and spends no credit. `resuming` is true while it runs — render a loading state, not an empty thread. `messages` is the running transcript, oldest first. You **cannot** set or read the system prompt, replay an edited transcript, or pick a model: the server assembles every turn from the stored prompt plus the stored history, and `content` is the only thing that reaches the wire. `reset()` drops the thread (a "new chat" affordance), and the new thread is the one a later mount resumes. **Every send spends the workspace's AI credits** and is rate-limited per caller AND per workspace. Rejects with `AgentError`; branch on `.code` — `AI_QUOTA_EXCEEDED` (429) is the workspace's credit ceiling and will NOT clear on a retry, unlike `RATE_LIMITED`, so never offer "try again" for it, and `AGENT_DISABLED` (409) means the author turned it off, so hide the affordance. Never surface a raw error or the workspace's billing state to an app user. `available` is false when no agent is bound or the host brokers none — render a visible unbound state, never a blank box. An assistant message that used a tool also carries **`activity`** — what the agent DID with the user's data: the table it read, or the table and columns it CHANGED (never the values, and never a row the permissions refused). **Render a write.** A reply saying "done!" is not evidence a row was written, and if the agent got it wrong the user has no other way to notice. A talk-only turn omits the key entirely. |
54
54
  | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*`. **To CLEAR a cell, pass it empty** — `update(id, { when: null })` (also `""` or `[]`) empties that cell and it reads back as `null` (`[]` on an array column). There is no separate clear call, so never write a sentinel such as `0` or a placeholder date to mean "no value". `false` and `0` are values, not clears, and a REQUIRED column cannot be cleared. |
55
55
  | **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
56
+ | **DATASTORE** | `useTyping(tableId, recordId)` | `{ typingUserIds, setTyping, status }` — status `"connecting" \| "live" \| "unavailable"` | `records(table).typing` — `datastore.read:<table>`. Who else is typing in one record, over the same socket as `useDatastoreSubscription`. Call `setTyping(true)` on every keystroke (throttled to one signal per 3 s; `false` is sent after 5 s idle) and `setTyping(false)` on send, clear or blur. `typingUserIds` lists the OTHER users' ids; each drops out 6 s after their last signal. Needs a signed-in user who can read the record. Never throws: an anonymous visitor, a refused subscription or a host whose client lacks `typing` reads `{ typingUserIds: [], status: "unavailable" }`. Re-subscribes when either id changes. |
56
57
  | **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
57
58
  | **DATASTORE** | `useCanWrite(tableId, options)` (optional: options) | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "may this caller write", reading the same table-ACL answer the write endpoint enforces — so a table granting Create to Everyone answers `true` for a logged-out visitor, and this hook alone is the right gate for a widget meant to work without signing in. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
58
59
  | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
@@ -75,7 +76,21 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
75
76
 
76
77
  ## Status
77
78
 
78
- `v0.189.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**.
79
+ `v0.190.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**.
80
+
81
+ ### What's new in 0.190.0 (contract 1.151.0)
82
+
83
+ **New `useTyping(tableId, recordId)` hook: a typing indicator for one record.** It returns `{ typingUserIds, setTyping, status }`. Call `setTyping(true)` on every keystroke in your composer and `setTyping(false)` when the message is sent, the field is cleared or it loses focus. The hook throttles for you: it sends at most one signal every 3 s, and sends `false` on its own after 5 s without a keystroke. `typingUserIds` lists the other users typing in that record (never yourself); each one drops out 6 s after their last signal, so a closed tab never leaves a stale indicator. Signals carry no message content and are never stored.
84
+
85
+ Key it on the record the conversation lives in (a thread or channel row), which the user must be able to read. It rides the same realtime connection as `useDatastoreSubscription`, so a widget holds one socket for both. `status` is `"connecting"`, `"live"` or `"unavailable"`; it is `"unavailable"` for an anonymous visitor, a refused subscription, a preview with no backend, and a host whose datastore client predates typing. The hook never throws, so render the indicator only when `typingUserIds` is non-empty:
86
+
87
+ ```jsx
88
+ const { typingUserIds, setTyping } = useTyping(threadsTableId, threadId);
89
+ // <TextInput onChangeText={(t) => { setDraft(t); setTyping(t.length > 0); }} onBlur={() => setTyping(false)} />
90
+ // {typingUserIds.length > 0 && <Text>{typingUserIds.length === 1 ? "Someone is typing…" : "Several people are typing…"}</Text>}
91
+ ```
92
+
93
+ Declare `datastore.read` for the table. Additive; works identically in the web Player and the native export.
79
94
 
80
95
  ### What's new in 0.189.0 (contract 1.150.0)
81
96
 
package/dist/contract.cjs CHANGED
@@ -2043,6 +2043,40 @@ const HOOKS = [
2043
2043
  requiredContextSlice: ["datastore.records"],
2044
2044
  scopes: ["datastore.read:<table>"],
2045
2045
  },
2046
+ // REQ-RT-08 — typing signals for one record.
2047
+ {
2048
+ name: "useTyping",
2049
+ signature: "useTyping(tableId, recordId)",
2050
+ description:
2051
+ "Who else is typing in one record, and announcing your own typing, via " +
2052
+ "ctx.datastore.records(tableId).typing(recordId, ...). It shares the " +
2053
+ "datastore client's one realtime socket with useDatastoreSubscription. " +
2054
+ "Call setTyping(true) on every keystroke (throttled inside: one signal " +
2055
+ "per 3 s at most, and false is sent after 5 s without a keystroke) and " +
2056
+ "setTyping(false) on send, clear or blur. typingUserIds lists the OTHER " +
2057
+ "users typing (user ids, never yourself); each drops out 6 s after their " +
2058
+ "last signal. Signals carry no message content and are never stored. " +
2059
+ "Typing needs a signed-in user who can read the record; key it on the " +
2060
+ "record the conversation lives in (a thread or channel row). Gate the " +
2061
+ "composer that calls setTyping on useCanWrite as usual. status is " +
2062
+ "'connecting' | 'live' | 'unavailable'. " +
2063
+ "Never throws: with a falsy id, an anonymous visitor, a refused " +
2064
+ "subscription, a host whose datastore client predates typing, or a " +
2065
+ "preview with no backend, it returns { typingUserIds: [], setTyping: no-op, " +
2066
+ "status: 'unavailable' }, so render the indicator only when " +
2067
+ "typingUserIds is non-empty. Re-subscribes when tableId or recordId " +
2068
+ "changes. Declare datastore.read for the table.",
2069
+ returnShape: {
2070
+ typingUserIds:
2071
+ "string[] // other users typing in this record; empty when unavailable",
2072
+ setTyping:
2073
+ "(active: boolean) => void // true per keystroke, false on send/clear/blur",
2074
+ status:
2075
+ "'connecting' | 'live' | 'unavailable' // 'unavailable' → hide the indicator",
2076
+ },
2077
+ requiredContextSlice: ["datastore.records"],
2078
+ scopes: ["datastore.read:<table>"],
2079
+ },
2046
2080
  // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
2047
2081
  {
2048
2082
  name: "useClipboard",
@@ -4923,7 +4957,11 @@ const CONTRACT = deepFreeze({
4923
4957
  // native-only file keeps every vetted subpath (Metro resolves them).
4924
4958
  // New `hostExternalSpecifiers` / `subpathImportRoots` lists and the
4925
4959
  // linter export `subpathImportRootsFor(manifest)`.
4926
- version: "1.150.0",
4960
+ // 1.151.0: additive (sc-10538) — new `useTyping(tableId, recordId)` returns
4961
+ // `{ typingUserIds, setTyping, status }`: the other users typing in one
4962
+ // record, over the datastore client's single realtime socket. A host
4963
+ // whose client lacks `records(t).typing` reads `status: "unavailable"`.
4964
+ version: "1.151.0",
4927
4965
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4928
4966
  hooks: HOOKS,
4929
4967
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -2043,6 +2043,40 @@ const HOOKS = [
2043
2043
  requiredContextSlice: ["datastore.records"],
2044
2044
  scopes: ["datastore.read:<table>"],
2045
2045
  },
2046
+ // REQ-RT-08 — typing signals for one record.
2047
+ {
2048
+ name: "useTyping",
2049
+ signature: "useTyping(tableId, recordId)",
2050
+ description:
2051
+ "Who else is typing in one record, and announcing your own typing, via " +
2052
+ "ctx.datastore.records(tableId).typing(recordId, ...). It shares the " +
2053
+ "datastore client's one realtime socket with useDatastoreSubscription. " +
2054
+ "Call setTyping(true) on every keystroke (throttled inside: one signal " +
2055
+ "per 3 s at most, and false is sent after 5 s without a keystroke) and " +
2056
+ "setTyping(false) on send, clear or blur. typingUserIds lists the OTHER " +
2057
+ "users typing (user ids, never yourself); each drops out 6 s after their " +
2058
+ "last signal. Signals carry no message content and are never stored. " +
2059
+ "Typing needs a signed-in user who can read the record; key it on the " +
2060
+ "record the conversation lives in (a thread or channel row). Gate the " +
2061
+ "composer that calls setTyping on useCanWrite as usual. status is " +
2062
+ "'connecting' | 'live' | 'unavailable'. " +
2063
+ "Never throws: with a falsy id, an anonymous visitor, a refused " +
2064
+ "subscription, a host whose datastore client predates typing, or a " +
2065
+ "preview with no backend, it returns { typingUserIds: [], setTyping: no-op, " +
2066
+ "status: 'unavailable' }, so render the indicator only when " +
2067
+ "typingUserIds is non-empty. Re-subscribes when tableId or recordId " +
2068
+ "changes. Declare datastore.read for the table.",
2069
+ returnShape: {
2070
+ typingUserIds:
2071
+ "string[] // other users typing in this record; empty when unavailable",
2072
+ setTyping:
2073
+ "(active: boolean) => void // true per keystroke, false on send/clear/blur",
2074
+ status:
2075
+ "'connecting' | 'live' | 'unavailable' // 'unavailable' → hide the indicator",
2076
+ },
2077
+ requiredContextSlice: ["datastore.records"],
2078
+ scopes: ["datastore.read:<table>"],
2079
+ },
2046
2080
  // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
2047
2081
  {
2048
2082
  name: "useClipboard",
@@ -4919,7 +4953,11 @@ const CONTRACT = deepFreeze({
4919
4953
  // native-only file keeps every vetted subpath (Metro resolves them).
4920
4954
  // New `hostExternalSpecifiers` / `subpathImportRoots` lists and the
4921
4955
  // linter export `subpathImportRootsFor(manifest)`.
4922
- version: "1.150.0",
4956
+ // 1.151.0: additive (sc-10538) — new `useTyping(tableId, recordId)` returns
4957
+ // `{ typingUserIds, setTyping, status }`: the other users typing in one
4958
+ // record, over the datastore client's single realtime socket. A host
4959
+ // whose client lacks `records(t).typing` reads `status: "unavailable"`.
4960
+ version: "1.151.0",
4923
4961
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4924
4962
  hooks: HOOKS,
4925
4963
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -3834,6 +3834,71 @@ export function useDatastoreSubscription(table, handlers, options) {
3834
3834
  return { status };
3835
3835
  }
3836
3836
 
3837
+ const _NO_TYPERS = Object.freeze([]);
3838
+
3839
+ /**
3840
+ * Who else is typing in one record; call `setTyping` per keystroke.
3841
+ * Never throws: a client without typing reports status "unavailable".
3842
+ *
3843
+ * @param {string} tableId
3844
+ * @param {string} recordId
3845
+ * @returns {{ typingUserIds: string[], setTyping: (active: boolean) => void, status: "connecting" | "live" | "unavailable" }}
3846
+ */
3847
+ export function useTyping(tableId, recordId) {
3848
+ const ctx = useWidgetContextOrThrow("useTyping");
3849
+ const [typingUserIds, setTypingUserIds] = useState(_NO_TYPERS);
3850
+ const [status, setStatus] = useState("connecting");
3851
+ const handleRef = useRef(null);
3852
+ // `ctx` is a fresh identity per host render; the effect keys on the ids.
3853
+ const recordsRef = useRef(null);
3854
+ recordsRef.current =
3855
+ ctx.datastore && typeof ctx.datastore.records === "function"
3856
+ ? ctx.datastore.records
3857
+ : null;
3858
+
3859
+ useEffect(() => {
3860
+ setTypingUserIds(_NO_TYPERS);
3861
+ setStatus("connecting");
3862
+ const unavailable = () => {
3863
+ setStatus("unavailable");
3864
+ return undefined;
3865
+ };
3866
+ if (!tableId || !recordId || typeof recordsRef.current !== "function") {
3867
+ return unavailable();
3868
+ }
3869
+ try {
3870
+ const ns = recordsRef.current(tableId);
3871
+ if (!ns || typeof ns.typing !== "function") return unavailable();
3872
+ let current = true;
3873
+ const handle = ns.typing(recordId, {
3874
+ onChange: (ids) => {
3875
+ if (current) setTypingUserIds(ids);
3876
+ },
3877
+ onStatus: (s) => {
3878
+ if (current) setStatus(s);
3879
+ },
3880
+ });
3881
+ handleRef.current = handle;
3882
+ return () => {
3883
+ current = false;
3884
+ handleRef.current = null;
3885
+ if (handle && typeof handle.stop === "function") handle.stop();
3886
+ };
3887
+ } catch (_) {
3888
+ return unavailable();
3889
+ }
3890
+ }, [tableId, recordId]);
3891
+
3892
+ const setTyping = useCallback((active) => {
3893
+ const handle = handleRef.current;
3894
+ if (handle && typeof handle.setTyping === "function") {
3895
+ handle.setTyping(active === true);
3896
+ }
3897
+ }, []);
3898
+
3899
+ return { typingUserIds, setTyping, status };
3900
+ }
3901
+
3837
3902
  /* ============================================================================
3838
3903
  * ASSETS CLIENT — ctx.assets (@colixsystems/assets-client)
3839
3904
  *
package/dist/index.d.ts CHANGED
@@ -496,6 +496,14 @@ export interface DatastoreClient {
496
496
  ): Promise<unknown>;
497
497
  revoke(permissionId: string): Promise<void>;
498
498
  };
499
+ /** REQ-RT-08 — backs `useTyping`; optional, the hook degrades without it. */
500
+ typing?(
501
+ recordId: string,
502
+ handlers: {
503
+ onChange?(typingUserIds: string[]): void;
504
+ onStatus?(status: TypingStatus): void;
505
+ },
506
+ ): { setTyping(active: boolean): void; stop(): void };
499
507
  };
500
508
  }
501
509
 
@@ -1410,6 +1418,27 @@ export function useDatastoreSubscription(
1410
1418
  options?: DatastoreSubscriptionOptions,
1411
1419
  ): { status: DatastoreSubscriptionStatus };
1412
1420
 
1421
+ export type TypingStatus = "connecting" | "live" | "unavailable";
1422
+
1423
+ /**
1424
+ * REQ-RT-08: who else is typing in one record, and announcing your own typing,
1425
+ * via `ctx.datastore.records(tableId).typing(recordId, ...)`. Call
1426
+ * `setTyping(true)` on every keystroke (throttled inside; `false` is sent
1427
+ * after 5 s idle) and `setTyping(false)` on send, clear or blur.
1428
+ * `typingUserIds` lists the other users typing; each drops out 6 s after
1429
+ * their last signal. Needs a signed-in user who can read the record.
1430
+ * Re-subscribes when `tableId` or `recordId` changes. Never throws — a host
1431
+ * whose datastore client lacks typing reports `status: "unavailable"`.
1432
+ */
1433
+ export function useTyping(
1434
+ tableId: string | null | undefined,
1435
+ recordId: string | null | undefined,
1436
+ ): {
1437
+ typingUserIds: string[];
1438
+ setTyping(active: boolean): void;
1439
+ status: TypingStatus;
1440
+ };
1441
+
1413
1442
  /**
1414
1443
  * Stateful file-asset resolver hook. Returns `{ url, file, loading, error,
1415
1444
  * refetch }`. The `url` is an absolute URL composed against the host's API
package/dist/index.js CHANGED
@@ -60,6 +60,7 @@ export {
60
60
  IdentificationError,
61
61
  useRecordPermissions,
62
62
  useDatastoreSubscription,
63
+ useTyping,
63
64
  useWidgetEvent,
64
65
  useWidgetInput,
65
66
  usePayments,
@@ -60,6 +60,7 @@ export {
60
60
  IdentificationError,
61
61
  useRecordPermissions,
62
62
  useDatastoreSubscription,
63
+ useTyping,
63
64
  useWidgetEvent,
64
65
  useWidgetInput,
65
66
  usePayments,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.189.0",
3
+ "version": "0.190.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",
package/src/contract.js CHANGED
@@ -2043,6 +2043,40 @@ const HOOKS = [
2043
2043
  requiredContextSlice: ["datastore.records"],
2044
2044
  scopes: ["datastore.read:<table>"],
2045
2045
  },
2046
+ // REQ-RT-08 — typing signals for one record.
2047
+ {
2048
+ name: "useTyping",
2049
+ signature: "useTyping(tableId, recordId)",
2050
+ description:
2051
+ "Who else is typing in one record, and announcing your own typing, via " +
2052
+ "ctx.datastore.records(tableId).typing(recordId, ...). It shares the " +
2053
+ "datastore client's one realtime socket with useDatastoreSubscription. " +
2054
+ "Call setTyping(true) on every keystroke (throttled inside: one signal " +
2055
+ "per 3 s at most, and false is sent after 5 s without a keystroke) and " +
2056
+ "setTyping(false) on send, clear or blur. typingUserIds lists the OTHER " +
2057
+ "users typing (user ids, never yourself); each drops out 6 s after their " +
2058
+ "last signal. Signals carry no message content and are never stored. " +
2059
+ "Typing needs a signed-in user who can read the record; key it on the " +
2060
+ "record the conversation lives in (a thread or channel row). Gate the " +
2061
+ "composer that calls setTyping on useCanWrite as usual. status is " +
2062
+ "'connecting' | 'live' | 'unavailable'. " +
2063
+ "Never throws: with a falsy id, an anonymous visitor, a refused " +
2064
+ "subscription, a host whose datastore client predates typing, or a " +
2065
+ "preview with no backend, it returns { typingUserIds: [], setTyping: no-op, " +
2066
+ "status: 'unavailable' }, so render the indicator only when " +
2067
+ "typingUserIds is non-empty. Re-subscribes when tableId or recordId " +
2068
+ "changes. Declare datastore.read for the table.",
2069
+ returnShape: {
2070
+ typingUserIds:
2071
+ "string[] // other users typing in this record; empty when unavailable",
2072
+ setTyping:
2073
+ "(active: boolean) => void // true per keystroke, false on send/clear/blur",
2074
+ status:
2075
+ "'connecting' | 'live' | 'unavailable' // 'unavailable' → hide the indicator",
2076
+ },
2077
+ requiredContextSlice: ["datastore.records"],
2078
+ scopes: ["datastore.read:<table>"],
2079
+ },
2046
2080
  // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
2047
2081
  {
2048
2082
  name: "useClipboard",
@@ -4919,7 +4953,11 @@ const CONTRACT = deepFreeze({
4919
4953
  // native-only file keeps every vetted subpath (Metro resolves them).
4920
4954
  // New `hostExternalSpecifiers` / `subpathImportRoots` lists and the
4921
4955
  // linter export `subpathImportRootsFor(manifest)`.
4922
- version: "1.150.0",
4956
+ // 1.151.0: additive (sc-10538) — new `useTyping(tableId, recordId)` returns
4957
+ // `{ typingUserIds, setTyping, status }`: the other users typing in one
4958
+ // record, over the datastore client's single realtime socket. A host
4959
+ // whose client lacks `records(t).typing` reads `status: "unavailable"`.
4960
+ version: "1.151.0",
4923
4961
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4924
4962
  hooks: HOOKS,
4925
4963
  primitives: PRIMITIVES,