@colixsystems/widget-sdk 0.159.0 → 0.161.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 +33 -5
- package/dist/contract.cjs +25 -2
- package/dist/contract.js +25 -2
- package/dist/hooks.js +235 -46
- package/dist/index.d.ts +30 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,12 +43,12 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
43
43
|
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
|
|
44
44
|
| **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
|
|
45
45
|
| **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
|
|
46
|
-
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
|
|
47
|
-
| **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
|
|
46
|
+
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
|
|
47
|
+
| **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, fetching, error, refetch }` — a refetch of the same record keeps `data` while `fetching` | `records(table).get` — `datastore.read:<table>` |
|
|
48
48
|
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
49
49
|
| **DATASTORE** | `useBoundColumns(tableId, shape, props)` | `{ columns, resolved, missing, loading, error }` | `schema(tableId)` (built on `useDatastoreSchema`) — `datastore.read:<table>`. Resolves author-bound column NAMES from `props` by exact name → case-insensitive name → first unclaimed column matching `shape[key].dataType`, so a column an author renamed after install still resolves instead of `record[props.titleField]` reading `undefined`. `columns` holds the resolved NAME (`record[columns.titleField]`); `resolved` holds the full `Column`; `missing` lists non-`optional` keys that never resolved. Falsy `tableId` collapses to `{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null }`. |
|
|
50
50
|
| **DATASTORE** | `useInterpretDraft(tableId)` | `{ interpret, interpreting, error, result, available }` | `interpret(tableId, body)` — `datastore.read:<table>`. Turns ONE sentence a user typed ("walk at 11 am tomorrow") into DRAFT column values so a form can prefill itself. IMPERATIVE: call `interpret(text, { fields?, timeZone? })` from an event handler, never on mount. It DRAFTS and writes nothing — show the values for review, then submit through `useDatastoreMutation().create`. Resolves to `{ values, unresolved }`; `values` is keyed by column NAME (the shape `create()` takes) and `unresolved` names the fields the sentence did not state. Only text / number / boolean / date / datetime / array columns are drafted — `FILE`, `RELATION`, `USER` and `USER_GROUP` carry ids and are never guessed. Fails closed to an empty draft. **Every call spends the workspace's AI credits** and is rate-limited per actor, so call it once per user action (never on mount or in a render loop); once the workspace runs out the call is refused with a generic 429 — an app user is deliberately **not** told the workspace's billing state, since they have never heard of an AI credit and cannot buy one. Never surface a raw error to the person filling the form: say drafting is unavailable and keep every field editable by hand. `available` is false where the host brokers no interpreter. |
|
|
51
|
-
| **AGENTS** | `useAgent(agentRef)` | `{ send, reset, messages, sending, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). 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. `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). **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. |
|
|
51
|
+
| **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. |
|
|
52
52
|
| **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. |
|
|
53
53
|
| **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. |
|
|
54
54
|
| **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) |
|
|
@@ -73,7 +73,35 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
73
73
|
|
|
74
74
|
## Status
|
|
75
75
|
|
|
76
|
-
`v0.
|
|
76
|
+
`v0.160.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.161.0 (contract 1.124.0)
|
|
79
|
+
|
|
80
|
+
**`useAgent()` resumes the conversation after a refresh or a navigation (sc-8867).** The backend has always persisted the thread, but the hook held the conversation id and the transcript in component state, so every remount opened a blank one. An app user who refreshed the page lost the conversation on screen — and re-explaining the context to the agent spent another paid turn.
|
|
81
|
+
|
|
82
|
+
On mount the hook now reopens the caller's most recent thread with the bound agent and restores its transcript. Two things are worth knowing:
|
|
83
|
+
|
|
84
|
+
- **It spends nothing.** The resume is two READ-ONLY GETs of the caller's own rows (`conversations.list` then `messages.list`). Sending stays imperative — no paid call is made on mount, so a widget nobody types into still costs the workspace nothing.
|
|
85
|
+
- **New `resuming` flag** — true only while the transcript is being reopened. Render a loading state: an empty thread that is about to fill reads as a lost conversation. It is never true for a turn in flight; that is still `sending`.
|
|
86
|
+
|
|
87
|
+
`reset()` is unchanged as a "new chat" affordance, and the new thread it starts is the one a later mount resumes. A transcript that fails to load renders no error — the widget is fully usable without it and the next send opens a fresh thread.
|
|
88
|
+
|
|
89
|
+
Additive: a widget that ignores `resuming` behaves as before, except that it now shows the conversation the user actually left.
|
|
90
|
+
|
|
91
|
+
### What's new in 0.160.0 (contract 1.123.0)
|
|
92
|
+
|
|
93
|
+
**`useDatastoreQuery` and `useDatastoreRecord` keep their data on screen while they re-fetch (sc-8595).** `loading` used to turn true on every fetch, so a widget that showed a spinner on `loading` flashed its content away whenever its query changed — most visibly when a sort or filter waited on `useBoundColumns`, where the first rows arrived before the schema and were replaced by the spinner a moment later. `loading` is now true only until the first result for the bound table (or record) lands. A changed query on that table, `refetch()` and pull-to-refresh keep the rows already in `data` with `loading: false`; the new **`fetching`** flag is true while any request is in flight. Binding a different table, or a different record id, still resets to `loading`.
|
|
94
|
+
|
|
95
|
+
Show a spinner on `loading` only. A query built from a bound column should pass `null` as the table until `useBoundColumns(...).loading` is false, so its first fetch already carries the column — and treat BOTH as loading, because the query reports `loading: false` while its table is still `null`:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
const { columns: bound, loading: columnsLoading } = useBoundColumns(tableId, shape, props);
|
|
99
|
+
const query = useStableQuery(() => ({ sort: { field: bound.publishedAtField, dir: "desc" }, limit: 5 }));
|
|
100
|
+
const { data, loading } = useDatastoreQuery(columnsLoading ? null : tableId, query);
|
|
101
|
+
if (columnsLoading || loading) return <Spinner />;
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Behaviour change: a widget that deliberately showed a spinner on every re-fetch now keeps its previous rows until the new ones arrive. `fetching` is there for an inline "updating" hint.
|
|
77
105
|
|
|
78
106
|
### What's new in 0.157.0 (contract 1.122.0 — unchanged)
|
|
79
107
|
|
|
@@ -186,7 +214,7 @@ It presents inside `<Overlay>`, so it escapes the widget's clipping box on both
|
|
|
186
214
|
A workspace **agent** is a name plus a system prompt its author wrote in the Studio. This hook lets any widget hold a conversation with one, without touching HTTP and without ever holding an API key.
|
|
187
215
|
|
|
188
216
|
```js
|
|
189
|
-
const { send, reset, messages, sending, error, available } = useAgent(props.agentId);
|
|
217
|
+
const { send, reset, messages, sending, resuming, error, available } = useAgent(props.agentId);
|
|
190
218
|
```
|
|
191
219
|
|
|
192
220
|
**What a widget deliberately cannot do.** You cannot set the system prompt, send your own transcript, or pick a model, and no API surface returns the prompt. The server assembles every turn from the stored prompt plus the stored history, and `content` is the only field that reaches the wire — not because the rest is filtered out, but because nothing else is ever sent. That guarantee is about the WIRE, not about secrecy: a model can be talked into repeating its own prompt, so an agent's prompt is not a secret store — never put credentials, or data the app's users may not see, in one. `messages` is the transcript this widget has seen; the real one is the server's.
|
package/dist/contract.cjs
CHANGED
|
@@ -913,9 +913,14 @@ const HOOKS = [
|
|
|
913
913
|
{
|
|
914
914
|
name: "useDatastoreRecord",
|
|
915
915
|
signature: "useDatastoreRecord(tableId, recordId)",
|
|
916
|
+
description:
|
|
917
|
+
"Reads one row. Re-reading the same record (refetch, pull-to-refresh) " +
|
|
918
|
+
"keeps it in `data` with loading false while `fetching` is true; a " +
|
|
919
|
+
"different table or record id resets to loading.",
|
|
916
920
|
returnShape: {
|
|
917
921
|
data: "Record | null",
|
|
918
922
|
loading: "boolean",
|
|
923
|
+
fetching: "boolean",
|
|
919
924
|
error: "DatastoreError | null",
|
|
920
925
|
refetch: "() => Promise<void>",
|
|
921
926
|
},
|
|
@@ -1205,9 +1210,16 @@ const HOOKS = [
|
|
|
1205
1210
|
name: "useDatastoreQuery",
|
|
1206
1211
|
signature: "useDatastoreQuery(tableId, options)",
|
|
1207
1212
|
optionalArgs: ["options"],
|
|
1213
|
+
description:
|
|
1214
|
+
"Reads a table's rows. Stale-while-revalidate: `loading` is true only " +
|
|
1215
|
+
"until the first result for the bound table lands; a changed query on " +
|
|
1216
|
+
"that table, refetch() and pull-to-refresh keep the rows already in " +
|
|
1217
|
+
"`data` with loading false, while `fetching` is true. Binding a " +
|
|
1218
|
+
"different table resets to loading. Show a spinner on `loading` only.",
|
|
1208
1219
|
returnShape: {
|
|
1209
1220
|
data: "Record[]",
|
|
1210
1221
|
loading: "boolean",
|
|
1222
|
+
fetching: "boolean",
|
|
1211
1223
|
error: "DatastoreError | null",
|
|
1212
1224
|
refetch: "() => Promise<void>",
|
|
1213
1225
|
},
|
|
@@ -1303,6 +1315,11 @@ const HOOKS = [
|
|
|
1303
1315
|
"agents (an author-written name + system prompt). IMPERATIVE: call " +
|
|
1304
1316
|
"send(text) from an event handler, never on mount — the first send " +
|
|
1305
1317
|
"opens the conversation, so a widget nobody types into costs nothing. " +
|
|
1318
|
+
"On mount the hook DOES reopen the caller's most recent thread with " +
|
|
1319
|
+
"this agent, so a refresh or a navigation keeps the conversation " +
|
|
1320
|
+
"instead of starting a blank one; that resume only READS and spends no " +
|
|
1321
|
+
"credit. `resuming` is true while it runs — render a loading state, " +
|
|
1322
|
+
"not an empty thread, or the transcript appears to pop in from nowhere. " +
|
|
1306
1323
|
"`messages` is the running transcript, oldest first, each " +
|
|
1307
1324
|
"{ id, role, content, created_at } with role 'user' | 'assistant'. " +
|
|
1308
1325
|
"An assistant message that used a tool also carries `activity`: what " +
|
|
@@ -1320,7 +1337,8 @@ const HOOKS = [
|
|
|
1320
1337
|
"`agentRef` property — when it is missing, or the host brokers no " +
|
|
1321
1338
|
"agents client, `available` is false and send() rejects UNAVAILABLE; " +
|
|
1322
1339
|
"render a visible unbound state, NEVER a blank box. reset() drops the " +
|
|
1323
|
-
"thread and clears the transcript (a 'new chat' affordance)
|
|
1340
|
+
"thread and clears the transcript (a 'new chat' affordance) and the " +
|
|
1341
|
+
"NEW thread is the one a later mount resumes. " +
|
|
1324
1342
|
"Every send spends the workspace's AI CREDITS and is rate-limited per " +
|
|
1325
1343
|
"caller AND per workspace, so call it once per user action. " +
|
|
1326
1344
|
"AgentError.code distinguishes the refusals that share a status: " +
|
|
@@ -1340,6 +1358,8 @@ const HOOKS = [
|
|
|
1340
1358
|
reset: "() => void",
|
|
1341
1359
|
messages: "Array<{ id, conversation_id, role, content, created_at }>",
|
|
1342
1360
|
sending: "boolean",
|
|
1361
|
+
resuming:
|
|
1362
|
+
"boolean // true only while the stored transcript is being reopened on mount; show a loading state, not an empty thread",
|
|
1343
1363
|
error: "AgentError | null",
|
|
1344
1364
|
available: "boolean // false when no agent is bound, or the host brokers none",
|
|
1345
1365
|
},
|
|
@@ -4334,7 +4354,10 @@ const CONTRACT = deepFreeze({
|
|
|
4334
4354
|
// layout it would use in a half-width cell. Additive: `isNarrowWidth` and
|
|
4335
4355
|
// `NARROW_WIDTH_PX` are unchanged, and `widthTier(w) === "narrow"` exactly
|
|
4336
4356
|
// when `isNarrowWidth(w)` is true.
|
|
4337
|
-
|
|
4357
|
+
// 1.123.0: behaviour (sc-8595) — useDatastoreQuery / useDatastoreRecord
|
|
4358
|
+
// keep their data while a changed query or refetch runs; `loading` only
|
|
4359
|
+
// until the first result for the table/record, new `fetching` flag.
|
|
4360
|
+
version: "1.124.0",
|
|
4338
4361
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4339
4362
|
hooks: HOOKS,
|
|
4340
4363
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -913,9 +913,14 @@ const HOOKS = [
|
|
|
913
913
|
{
|
|
914
914
|
name: "useDatastoreRecord",
|
|
915
915
|
signature: "useDatastoreRecord(tableId, recordId)",
|
|
916
|
+
description:
|
|
917
|
+
"Reads one row. Re-reading the same record (refetch, pull-to-refresh) " +
|
|
918
|
+
"keeps it in `data` with loading false while `fetching` is true; a " +
|
|
919
|
+
"different table or record id resets to loading.",
|
|
916
920
|
returnShape: {
|
|
917
921
|
data: "Record | null",
|
|
918
922
|
loading: "boolean",
|
|
923
|
+
fetching: "boolean",
|
|
919
924
|
error: "DatastoreError | null",
|
|
920
925
|
refetch: "() => Promise<void>",
|
|
921
926
|
},
|
|
@@ -1205,9 +1210,16 @@ const HOOKS = [
|
|
|
1205
1210
|
name: "useDatastoreQuery",
|
|
1206
1211
|
signature: "useDatastoreQuery(tableId, options)",
|
|
1207
1212
|
optionalArgs: ["options"],
|
|
1213
|
+
description:
|
|
1214
|
+
"Reads a table's rows. Stale-while-revalidate: `loading` is true only " +
|
|
1215
|
+
"until the first result for the bound table lands; a changed query on " +
|
|
1216
|
+
"that table, refetch() and pull-to-refresh keep the rows already in " +
|
|
1217
|
+
"`data` with loading false, while `fetching` is true. Binding a " +
|
|
1218
|
+
"different table resets to loading. Show a spinner on `loading` only.",
|
|
1208
1219
|
returnShape: {
|
|
1209
1220
|
data: "Record[]",
|
|
1210
1221
|
loading: "boolean",
|
|
1222
|
+
fetching: "boolean",
|
|
1211
1223
|
error: "DatastoreError | null",
|
|
1212
1224
|
refetch: "() => Promise<void>",
|
|
1213
1225
|
},
|
|
@@ -1303,6 +1315,11 @@ const HOOKS = [
|
|
|
1303
1315
|
"agents (an author-written name + system prompt). IMPERATIVE: call " +
|
|
1304
1316
|
"send(text) from an event handler, never on mount — the first send " +
|
|
1305
1317
|
"opens the conversation, so a widget nobody types into costs nothing. " +
|
|
1318
|
+
"On mount the hook DOES reopen the caller's most recent thread with " +
|
|
1319
|
+
"this agent, so a refresh or a navigation keeps the conversation " +
|
|
1320
|
+
"instead of starting a blank one; that resume only READS and spends no " +
|
|
1321
|
+
"credit. `resuming` is true while it runs — render a loading state, " +
|
|
1322
|
+
"not an empty thread, or the transcript appears to pop in from nowhere. " +
|
|
1306
1323
|
"`messages` is the running transcript, oldest first, each " +
|
|
1307
1324
|
"{ id, role, content, created_at } with role 'user' | 'assistant'. " +
|
|
1308
1325
|
"An assistant message that used a tool also carries `activity`: what " +
|
|
@@ -1320,7 +1337,8 @@ const HOOKS = [
|
|
|
1320
1337
|
"`agentRef` property — when it is missing, or the host brokers no " +
|
|
1321
1338
|
"agents client, `available` is false and send() rejects UNAVAILABLE; " +
|
|
1322
1339
|
"render a visible unbound state, NEVER a blank box. reset() drops the " +
|
|
1323
|
-
"thread and clears the transcript (a 'new chat' affordance)
|
|
1340
|
+
"thread and clears the transcript (a 'new chat' affordance) and the " +
|
|
1341
|
+
"NEW thread is the one a later mount resumes. " +
|
|
1324
1342
|
"Every send spends the workspace's AI CREDITS and is rate-limited per " +
|
|
1325
1343
|
"caller AND per workspace, so call it once per user action. " +
|
|
1326
1344
|
"AgentError.code distinguishes the refusals that share a status: " +
|
|
@@ -1340,6 +1358,8 @@ const HOOKS = [
|
|
|
1340
1358
|
reset: "() => void",
|
|
1341
1359
|
messages: "Array<{ id, conversation_id, role, content, created_at }>",
|
|
1342
1360
|
sending: "boolean",
|
|
1361
|
+
resuming:
|
|
1362
|
+
"boolean // true only while the stored transcript is being reopened on mount; show a loading state, not an empty thread",
|
|
1343
1363
|
error: "AgentError | null",
|
|
1344
1364
|
available: "boolean // false when no agent is bound, or the host brokers none",
|
|
1345
1365
|
},
|
|
@@ -4330,7 +4350,10 @@ const CONTRACT = deepFreeze({
|
|
|
4330
4350
|
// layout it would use in a half-width cell. Additive: `isNarrowWidth` and
|
|
4331
4351
|
// `NARROW_WIDTH_PX` are unchanged, and `widthTier(w) === "narrow"` exactly
|
|
4332
4352
|
// when `isNarrowWidth(w)` is true.
|
|
4333
|
-
|
|
4353
|
+
// 1.123.0: behaviour (sc-8595) — useDatastoreQuery / useDatastoreRecord
|
|
4354
|
+
// keep their data while a changed query or refetch runs; `loading` only
|
|
4355
|
+
// until the first result for the table/record, new `fetching` flag.
|
|
4356
|
+
version: "1.124.0",
|
|
4334
4357
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4335
4358
|
hooks: HOOKS,
|
|
4336
4359
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -2173,8 +2173,23 @@ function toDatastoreError(err) {
|
|
|
2173
2173
|
// on a genuine loop.
|
|
2174
2174
|
const VOLATILE_QUERY_RENDER_LIMIT = 10;
|
|
2175
2175
|
|
|
2176
|
+
const NO_QUERY_RESULT = { key: null, table: null, data: null, error: null, loaded: false };
|
|
2177
|
+
const NO_RECORD_RESULT = { key: null, data: null, error: null, loaded: false };
|
|
2178
|
+
|
|
2179
|
+
function serializeQuery(query) {
|
|
2180
|
+
try {
|
|
2181
|
+
return JSON.stringify(query);
|
|
2182
|
+
} catch (_e) {
|
|
2183
|
+
return null;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
const queryResultKey = (table, queryKey) => JSON.stringify([table, queryKey]);
|
|
2188
|
+
const recordResultKey = (table, recordId) => JSON.stringify([table, recordId]);
|
|
2189
|
+
|
|
2176
2190
|
/**
|
|
2177
|
-
* Stateful datastore query hook. Returns { data, loading, error,
|
|
2191
|
+
* Stateful datastore query hook. Returns { data, loading, fetching, error,
|
|
2192
|
+
* refetch }.
|
|
2178
2193
|
*
|
|
2179
2194
|
* The host injects a `@colixsystems/datastore-client` instance at
|
|
2180
2195
|
* `ctx.datastore`. We call `ctx.datastore.records(table).list(query)`, which
|
|
@@ -2187,9 +2202,18 @@ const VOLATILE_QUERY_RENDER_LIMIT = 10;
|
|
|
2187
2202
|
* re-fetch when [table, JSON.stringify(query)] changes. `refetch` re-runs the
|
|
2188
2203
|
* same call on demand.
|
|
2189
2204
|
*
|
|
2205
|
+
* sc-8595 — stale-while-revalidate. `loading` is true only until the first
|
|
2206
|
+
* result for the bound TABLE lands. A changed query on that table, `refetch()`
|
|
2207
|
+
* and a page pull-to-refresh keep the rows already in `data` on screen with
|
|
2208
|
+
* `loading: false`; `fetching` is true while any request is in flight. Binding
|
|
2209
|
+
* a different table resets to `{ data: [], loading: true }`. So a widget shows
|
|
2210
|
+
* its spinner on `loading` only, and never flashes back to it when its query
|
|
2211
|
+
* settles (e.g. a sort that waits on `useBoundColumns`).
|
|
2212
|
+
*
|
|
2190
2213
|
* When `table` is falsy (e.g. the user hasn't bound a `tableRef` property
|
|
2191
|
-
* yet), the hook resolves to { data: [], loading: false,
|
|
2192
|
-
* refetch } so the widget can render its empty state without
|
|
2214
|
+
* yet), the hook resolves to { data: [], loading: false, fetching: false,
|
|
2215
|
+
* error: null, refetch } so the widget can render its empty state without
|
|
2216
|
+
* throwing.
|
|
2193
2217
|
*/
|
|
2194
2218
|
export function useDatastoreQuery(table, query) {
|
|
2195
2219
|
const ctx = useWidgetContextOrThrow("useDatastoreQuery");
|
|
@@ -2198,9 +2222,12 @@ export function useDatastoreQuery(table, query) {
|
|
|
2198
2222
|
"useDatastoreQuery: host did not inject a datastore client",
|
|
2199
2223
|
);
|
|
2200
2224
|
}
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2225
|
+
// sc-8595: the last settled request, stamped with the arguments it
|
|
2226
|
+
// answered. What renders is DERIVED from it (see the return), so no
|
|
2227
|
+
// committed frame shows another table's rows or a stale `loading`.
|
|
2228
|
+
const [settled, setSettled] = useState(NO_QUERY_RESULT);
|
|
2229
|
+
const [inFlight, setInFlight] = useState(false);
|
|
2230
|
+
const emptyRows = useRef([]).current;
|
|
2204
2231
|
|
|
2205
2232
|
// Capture the latest table + query in refs so refetch() — a stable
|
|
2206
2233
|
// callback identity — always reads the current arguments without having
|
|
@@ -2230,13 +2257,12 @@ export function useDatastoreQuery(table, query) {
|
|
|
2230
2257
|
if (!t) {
|
|
2231
2258
|
// No table bound yet — collapse to a stable empty result without
|
|
2232
2259
|
// a network round-trip.
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
setData([]);
|
|
2260
|
+
setInFlight(false);
|
|
2261
|
+
setSettled(NO_QUERY_RESULT);
|
|
2236
2262
|
return;
|
|
2237
2263
|
}
|
|
2238
|
-
|
|
2239
|
-
|
|
2264
|
+
const key = queryResultKey(t, serializeQuery(queryRef.current));
|
|
2265
|
+
setInFlight(true);
|
|
2240
2266
|
try {
|
|
2241
2267
|
const ns = recordsRef.current(t);
|
|
2242
2268
|
const res = await ns.list(queryRef.current);
|
|
@@ -2245,23 +2271,23 @@ export function useDatastoreQuery(table, query) {
|
|
|
2245
2271
|
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
2246
2272
|
// Discard the result if a newer fetch has started since we kicked off.
|
|
2247
2273
|
if (runRef.current !== myRun) return;
|
|
2248
|
-
|
|
2249
|
-
|
|
2274
|
+
setSettled({ key, table: t, data: rows, error: null, loaded: true });
|
|
2275
|
+
setInFlight(false);
|
|
2250
2276
|
} catch (err) {
|
|
2251
2277
|
if (runRef.current !== myRun) return;
|
|
2252
|
-
|
|
2253
|
-
|
|
2278
|
+
const error = toDatastoreError(err);
|
|
2279
|
+
// A failed re-fetch keeps the table's rows; a failed first load has none.
|
|
2280
|
+
setSettled((prev) =>
|
|
2281
|
+
prev.table === t && prev.loaded
|
|
2282
|
+
? { ...prev, key, error }
|
|
2283
|
+
: { key, table: t, data: emptyRows, error, loaded: false },
|
|
2284
|
+
);
|
|
2285
|
+
setInFlight(false);
|
|
2254
2286
|
}
|
|
2255
2287
|
}, []);
|
|
2256
2288
|
|
|
2257
2289
|
// Re-run on mount and whenever [table, JSON.stringify(query)] changes.
|
|
2258
|
-
const queryKey = (
|
|
2259
|
-
try {
|
|
2260
|
-
return JSON.stringify(query);
|
|
2261
|
-
} catch (_e) {
|
|
2262
|
-
return null;
|
|
2263
|
-
}
|
|
2264
|
-
})();
|
|
2290
|
+
const queryKey = serializeQuery(query);
|
|
2265
2291
|
|
|
2266
2292
|
// sc-1579 — a query argument built from a volatile value (`Date.now()`,
|
|
2267
2293
|
// `new Date()`, `Math.random()`) serialises differently every render, so the
|
|
@@ -2304,12 +2330,21 @@ export function useDatastoreQuery(table, query) {
|
|
|
2304
2330
|
// pull-to-refresh on mobile re-runs the query without per-widget code.
|
|
2305
2331
|
useRefresh(refetch);
|
|
2306
2332
|
|
|
2307
|
-
|
|
2333
|
+
const currentKey = table ? queryResultKey(table, queryKey) : null;
|
|
2334
|
+
const busy = Boolean(table) && (settled.key !== currentKey || inFlight);
|
|
2335
|
+
const hasRows = Boolean(table) && settled.table === table && settled.loaded;
|
|
2336
|
+
return {
|
|
2337
|
+
data: hasRows ? settled.data : emptyRows,
|
|
2338
|
+
loading: !hasRows && busy,
|
|
2339
|
+
fetching: busy,
|
|
2340
|
+
error: table && !busy && settled.table === table ? settled.error : null,
|
|
2341
|
+
refetch,
|
|
2342
|
+
};
|
|
2308
2343
|
}
|
|
2309
2344
|
|
|
2310
2345
|
/**
|
|
2311
|
-
* Stateful single-record query hook. Returns { data, loading, error,
|
|
2312
|
-
* where `data` is one row (`null` until loaded, never an array).
|
|
2346
|
+
* Stateful single-record query hook. Returns { data, loading, fetching, error,
|
|
2347
|
+
* refetch } where `data` is one row (`null` until loaded, never an array).
|
|
2313
2348
|
*
|
|
2314
2349
|
* The host injects a `@colixsystems/datastore-client` instance at
|
|
2315
2350
|
* `ctx.datastore`. We call `ctx.datastore.records(table).get(id)`, which
|
|
@@ -2318,9 +2353,13 @@ export function useDatastoreQuery(table, query) {
|
|
|
2318
2353
|
* named them. We hold the result in component state and re-fetch when
|
|
2319
2354
|
* [table, id] changes. `refetch` re-runs the call on demand.
|
|
2320
2355
|
*
|
|
2356
|
+
* sc-8595 — same rule as useDatastoreQuery: re-reading the SAME record keeps
|
|
2357
|
+
* it in `data` with `loading: false` while `fetching` is true; a different
|
|
2358
|
+
* table or id resets to `{ data: null, loading: true }`.
|
|
2359
|
+
*
|
|
2321
2360
|
* When `table` OR `recordId` is falsy (e.g. the author hasn't bound the
|
|
2322
2361
|
* record id yet), the hook resolves to { data: null, loading: false,
|
|
2323
|
-
* error: null, refetch } so the widget can render its empty state
|
|
2362
|
+
* fetching: false, error: null, refetch } so the widget can render its empty state
|
|
2324
2363
|
* without throwing. A 404 surfaces as a DatastoreError with
|
|
2325
2364
|
* `code: "NOT_FOUND"`.
|
|
2326
2365
|
*/
|
|
@@ -2331,10 +2370,10 @@ export function useDatastoreRecord(table, recordId) {
|
|
|
2331
2370
|
"useDatastoreRecord: host did not inject a datastore client",
|
|
2332
2371
|
);
|
|
2333
2372
|
}
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
const [
|
|
2337
|
-
const [
|
|
2373
|
+
// sc-8595: stamped with the record it answered; rendered values derive
|
|
2374
|
+
// from it, like useDatastoreQuery's.
|
|
2375
|
+
const [settled, setSettled] = useState(NO_RECORD_RESULT);
|
|
2376
|
+
const [inFlight, setInFlight] = useState(false);
|
|
2338
2377
|
|
|
2339
2378
|
// Same ref discipline as useDatastoreQuery — `ctx` is a fresh object
|
|
2340
2379
|
// identity on every host render, so we hold the live inputs in refs
|
|
@@ -2353,23 +2392,27 @@ export function useDatastoreRecord(table, recordId) {
|
|
|
2353
2392
|
const t = tableRef.current;
|
|
2354
2393
|
const id = recordIdRef.current;
|
|
2355
2394
|
if (!t || !id) {
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
setData(null);
|
|
2395
|
+
setInFlight(false);
|
|
2396
|
+
setSettled(NO_RECORD_RESULT);
|
|
2359
2397
|
return;
|
|
2360
2398
|
}
|
|
2361
|
-
|
|
2362
|
-
|
|
2399
|
+
const key = recordResultKey(t, id);
|
|
2400
|
+
setInFlight(true);
|
|
2363
2401
|
try {
|
|
2364
2402
|
const ns = recordsRef.current(t);
|
|
2365
2403
|
const row = await ns.get(id);
|
|
2366
2404
|
if (runRef.current !== myRun) return;
|
|
2367
|
-
|
|
2368
|
-
|
|
2405
|
+
setSettled({ key, data: row || null, error: null, loaded: true });
|
|
2406
|
+
setInFlight(false);
|
|
2369
2407
|
} catch (err) {
|
|
2370
2408
|
if (runRef.current !== myRun) return;
|
|
2371
|
-
|
|
2372
|
-
|
|
2409
|
+
const error = toDatastoreError(err);
|
|
2410
|
+
setSettled((prev) =>
|
|
2411
|
+
prev.key === key && prev.loaded
|
|
2412
|
+
? { ...prev, error }
|
|
2413
|
+
: { key, data: null, error, loaded: false },
|
|
2414
|
+
);
|
|
2415
|
+
setInFlight(false);
|
|
2373
2416
|
}
|
|
2374
2417
|
}, []);
|
|
2375
2418
|
|
|
@@ -2385,7 +2428,16 @@ export function useDatastoreRecord(table, recordId) {
|
|
|
2385
2428
|
// sc-1179 — auto-subscribe to the page-level refresh tick.
|
|
2386
2429
|
useRefresh(refetch);
|
|
2387
2430
|
|
|
2388
|
-
|
|
2431
|
+
const currentKey = table && recordId ? recordResultKey(table, recordId) : null;
|
|
2432
|
+
const busy = currentKey !== null && (settled.key !== currentKey || inFlight);
|
|
2433
|
+
const hasRecord = currentKey !== null && settled.key === currentKey && settled.loaded;
|
|
2434
|
+
return {
|
|
2435
|
+
data: hasRecord ? settled.data : null,
|
|
2436
|
+
loading: !hasRecord && busy,
|
|
2437
|
+
fetching: busy,
|
|
2438
|
+
error: currentKey !== null && !busy ? settled.error : null,
|
|
2439
|
+
refetch,
|
|
2440
|
+
};
|
|
2389
2441
|
}
|
|
2390
2442
|
|
|
2391
2443
|
/**
|
|
@@ -2761,17 +2813,23 @@ function toAgentError(err) {
|
|
|
2761
2813
|
|
|
2762
2814
|
/**
|
|
2763
2815
|
* sc-7991 — hold a conversation with a workspace-authored AI agent.
|
|
2764
|
-
* Returns `{ send, reset, messages, sending, error, available }`.
|
|
2816
|
+
* Returns `{ send, cancel, reset, messages, sending, resuming, error, available }`.
|
|
2765
2817
|
*
|
|
2766
2818
|
* send(text) → Promise<{ role, content }> the agent's reply
|
|
2767
2819
|
*
|
|
2768
|
-
*
|
|
2769
|
-
* from an event handler
|
|
2770
|
-
* who places the widget and never types costs the workspace nothing.
|
|
2820
|
+
* Sending is IMPERATIVE — no PAID call is ever made on mount; the widget calls
|
|
2821
|
+
* `send` from an event handler, and the first `send` opens the conversation, so
|
|
2822
|
+
* an author who places the widget and never types costs the workspace nothing.
|
|
2823
|
+
*
|
|
2824
|
+
* sc-8867 — on mount the hook does reopen the caller's most recent thread with
|
|
2825
|
+
* this agent, so a refresh or a navigation keeps the conversation instead of
|
|
2826
|
+
* starting a blank one. That resume is two READ-ONLY GETs of the caller's own
|
|
2827
|
+
* rows and spends no AI credit. `resuming` is true only while it runs; `reset()`
|
|
2828
|
+
* starts a fresh thread and outdates any resume still in flight.
|
|
2771
2829
|
*
|
|
2772
2830
|
* `messages` is the running transcript this widget has seen, oldest first,
|
|
2773
|
-
* each `{ id, role, content, created_at }` with `role` of "user" | "assistant".
|
|
2774
|
-
* An assistant message that used a tool also carries `activity` — what the
|
|
2831
|
+
* each `{ id, role, content, created_at }` with `role` of "user" | "assistant".
|
|
2832
|
+
* An assistant message that used a tool also carries `activity` — what the
|
|
2775
2833
|
* agent DID with the user's data. Render the writes: "done!" is not evidence.
|
|
2776
2834
|
* The SERVER owns the real transcript and the system prompt: the widget sends
|
|
2777
2835
|
* one message and can neither read nor set the prompt.
|
|
@@ -2808,12 +2866,25 @@ export function useAgent(agentRef) {
|
|
|
2808
2866
|
const [messages, setMessages] = useState([]);
|
|
2809
2867
|
const [sending, setSending] = useState(false);
|
|
2810
2868
|
const [error, setError] = useState(null);
|
|
2869
|
+
const [resuming, setResuming] = useState(available);
|
|
2811
2870
|
|
|
2812
2871
|
// Set during render when the binding changes; consumed below. A setState in
|
|
2813
2872
|
// the render body would be a cascading re-render, so the flag is applied to
|
|
2814
2873
|
// the value this render returns instead.
|
|
2815
2874
|
const pendingResetRef = useRef(false);
|
|
2816
2875
|
|
|
2876
|
+
// sc-8867 — a `reset()` must SURVIVE the resume effect. Without it, "New
|
|
2877
|
+
// conversation" on a mount whose list call is still in flight is overwritten
|
|
2878
|
+
// by the transcript the user just asked to leave.
|
|
2879
|
+
const resumeGenerationRef = useRef(0);
|
|
2880
|
+
|
|
2881
|
+
// "New conversation" is a decision about the THREAD, not about this mount.
|
|
2882
|
+
// `reset()` drops only the client's handle — the server still lists the
|
|
2883
|
+
// abandoned thread as the most recent one — so without this a remount
|
|
2884
|
+
// resumes exactly what the user just dismissed. Cleared on a rebind, which
|
|
2885
|
+
// is a different agent and therefore a different decision.
|
|
2886
|
+
const suppressResumeRef = useRef(false);
|
|
2887
|
+
|
|
2817
2888
|
// A changed binding is a different agent, so neither the open thread nor the
|
|
2818
2889
|
// transcript carries — leaving the messages would render agent A's
|
|
2819
2890
|
// conversation underneath agent B.
|
|
@@ -2822,14 +2893,25 @@ export function useAgent(agentRef) {
|
|
|
2822
2893
|
boundRef.current = agentRef;
|
|
2823
2894
|
conversationRef.current = null;
|
|
2824
2895
|
pendingResetRef.current = true;
|
|
2896
|
+
// A resume for the PREVIOUS agent is now outdated, and the new binding's
|
|
2897
|
+
// own resume has not run yet — without this the widget renders one frame
|
|
2898
|
+
// of "no conversation" before the effect reports it is loading.
|
|
2899
|
+
resumeGenerationRef.current += 1;
|
|
2900
|
+
// A different agent is a different decision: leaving the old agent's
|
|
2901
|
+
// "new conversation" set would stop this one from ever resuming.
|
|
2902
|
+
suppressResumeRef.current = false;
|
|
2825
2903
|
}
|
|
2826
2904
|
|
|
2827
2905
|
const abortRef = useRef(null);
|
|
2828
2906
|
|
|
2829
2907
|
const reset = useCallback(() => {
|
|
2908
|
+
// Outdates any resume in flight, so its transcript is discarded on arrival.
|
|
2909
|
+
resumeGenerationRef.current += 1;
|
|
2910
|
+
suppressResumeRef.current = true;
|
|
2830
2911
|
conversationRef.current = null;
|
|
2831
2912
|
setMessages([]);
|
|
2832
2913
|
setError(null);
|
|
2914
|
+
setResuming(false);
|
|
2833
2915
|
}, []);
|
|
2834
2916
|
|
|
2835
2917
|
// Ends the WAIT, not the turn: the abort never reaches the server, which
|
|
@@ -2839,6 +2921,104 @@ export function useAgent(agentRef) {
|
|
|
2839
2921
|
if (controller) controller.abort();
|
|
2840
2922
|
}, []);
|
|
2841
2923
|
|
|
2924
|
+
// sc-8867 — reopen the caller's most recent thread with this agent.
|
|
2925
|
+
//
|
|
2926
|
+
// The transcript lives on the SERVER; holding it only in component state
|
|
2927
|
+
// meant a refresh or a navigation started a blank thread, losing the
|
|
2928
|
+
// context and paying to rebuild it. Both calls here are READ-ONLY GETs of
|
|
2929
|
+
// the caller's own rows: this spends no AI credit, so the hook's rule that
|
|
2930
|
+
// it never makes a PAID call on mount is intact — `send` and
|
|
2931
|
+
// `conversations.create` remain imperative and reachable only from an
|
|
2932
|
+
// event handler.
|
|
2933
|
+
//
|
|
2934
|
+
// Nothing is resumed when the transcript is non-empty (a send already
|
|
2935
|
+
// opened this thread) or after a `reset()`, whose generation bump outdates
|
|
2936
|
+
// an in-flight resume.
|
|
2937
|
+
useEffect(() => {
|
|
2938
|
+
if (!available) {
|
|
2939
|
+
setResuming(false);
|
|
2940
|
+
return undefined;
|
|
2941
|
+
}
|
|
2942
|
+
const client = clientRef.current;
|
|
2943
|
+
const agentId = agentIdRef.current;
|
|
2944
|
+
if (!client || !agentId) {
|
|
2945
|
+
setResuming(false);
|
|
2946
|
+
return undefined;
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
let cancelled = false;
|
|
2950
|
+
const generation = resumeGenerationRef.current;
|
|
2951
|
+
// A remount with the thread already open would re-read what it holds, and
|
|
2952
|
+
// a thread the user deliberately left must not come back on a remount.
|
|
2953
|
+
if (conversationRef.current || suppressResumeRef.current) {
|
|
2954
|
+
setResuming(false);
|
|
2955
|
+
return undefined;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
// An older host may broker a client with no `list`. Resuming is an
|
|
2959
|
+
// ENHANCEMENT, so a client that cannot do it degrades to the previous
|
|
2960
|
+
// behaviour — never to a widget wedged on a loading state it cannot leave.
|
|
2961
|
+
let thread;
|
|
2962
|
+
try {
|
|
2963
|
+
thread = client.conversations(agentId);
|
|
2964
|
+
} catch {
|
|
2965
|
+
thread = null;
|
|
2966
|
+
}
|
|
2967
|
+
if (!thread || typeof thread.list !== "function") {
|
|
2968
|
+
setResuming(false);
|
|
2969
|
+
return undefined;
|
|
2970
|
+
}
|
|
2971
|
+
setResuming(true);
|
|
2972
|
+
|
|
2973
|
+
(async () => {
|
|
2974
|
+
try {
|
|
2975
|
+
const listed = await thread.list({ limit: 1 });
|
|
2976
|
+
const latest =
|
|
2977
|
+
listed && Array.isArray(listed.data) && listed.data.length > 0
|
|
2978
|
+
? listed.data[0]
|
|
2979
|
+
: null;
|
|
2980
|
+
const conversationId = latest && latest.id ? latest.id : null;
|
|
2981
|
+
if (!conversationId) return;
|
|
2982
|
+
const transcript = thread.messages(conversationId);
|
|
2983
|
+
if (!transcript || typeof transcript.list !== "function") return;
|
|
2984
|
+
const history = await transcript.list();
|
|
2985
|
+
// Re-checked AFTER the awaits: a rebind, an unmount or a reset during
|
|
2986
|
+
// the round trip must not adopt this agent's thread under another.
|
|
2987
|
+
if (
|
|
2988
|
+
cancelled ||
|
|
2989
|
+
agentIdRef.current !== agentId ||
|
|
2990
|
+
resumeGenerationRef.current !== generation ||
|
|
2991
|
+
suppressResumeRef.current ||
|
|
2992
|
+
// A `send` that started during the resume already latched a thread.
|
|
2993
|
+
// Overwriting it would orphan that PAID turn in a conversation
|
|
2994
|
+
// nothing reads again, and point the next send somewhere else.
|
|
2995
|
+
conversationRef.current
|
|
2996
|
+
) {
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
conversationRef.current = Promise.resolve(conversationId);
|
|
3000
|
+
const rows =
|
|
3001
|
+
history && Array.isArray(history.data) ? history.data : [];
|
|
3002
|
+
// Never clobber a turn a concurrent send already appended.
|
|
3003
|
+
if (rows.length > 0) setMessages((prior) => (prior.length > 0 ? prior : rows));
|
|
3004
|
+
} catch {
|
|
3005
|
+
// A transcript that will not load is not a failed turn, and the widget
|
|
3006
|
+
// is fully usable without it: a visible error here would report a
|
|
3007
|
+
// healthy agent as broken. The next send opens a fresh thread.
|
|
3008
|
+
} finally {
|
|
3009
|
+
if (!cancelled) setResuming(false);
|
|
3010
|
+
}
|
|
3011
|
+
})();
|
|
3012
|
+
|
|
3013
|
+
return () => {
|
|
3014
|
+
cancelled = true;
|
|
3015
|
+
// A hung `list()` never settles, so the `finally` above may never run.
|
|
3016
|
+
// Without this the next render stays latched on a spinner with the
|
|
3017
|
+
// composer disabled — the wedge this whole path exists to avoid.
|
|
3018
|
+
setResuming(false);
|
|
3019
|
+
};
|
|
3020
|
+
}, [available, agentRef]);
|
|
3021
|
+
|
|
2842
3022
|
const send = useCallback(async (text) => {
|
|
2843
3023
|
const client = clientRef.current;
|
|
2844
3024
|
const agentId = agentIdRef.current;
|
|
@@ -2910,6 +3090,7 @@ export function useAgent(agentRef) {
|
|
|
2910
3090
|
// than one frame of the previous agent's conversation.
|
|
2911
3091
|
let visibleMessages = messages;
|
|
2912
3092
|
let visibleError = error;
|
|
3093
|
+
let visibleResuming = resuming;
|
|
2913
3094
|
if (pendingResetRef.current) {
|
|
2914
3095
|
pendingResetRef.current = false;
|
|
2915
3096
|
visibleMessages = [];
|
|
@@ -2918,6 +3099,10 @@ export function useAgent(agentRef) {
|
|
|
2918
3099
|
// report a healthy agent as broken, and widgets gate on error.code.
|
|
2919
3100
|
visibleError = null;
|
|
2920
3101
|
if (error) setError(null);
|
|
3102
|
+
// The new binding's resume starts in the effect below, so report it as
|
|
3103
|
+
// loading now rather than showing an empty thread for one frame.
|
|
3104
|
+
visibleResuming = available;
|
|
3105
|
+
if (resuming !== available) setResuming(available);
|
|
2921
3106
|
}
|
|
2922
3107
|
|
|
2923
3108
|
return {
|
|
@@ -2926,6 +3111,10 @@ export function useAgent(agentRef) {
|
|
|
2926
3111
|
reset,
|
|
2927
3112
|
messages: visibleMessages,
|
|
2928
3113
|
sending,
|
|
3114
|
+
// sc-8867 — true only while the stored transcript is being reopened, so a
|
|
3115
|
+
// widget shows a loading state instead of an empty thread that is about to
|
|
3116
|
+
// fill. Never true for a turn in flight; that is `sending`.
|
|
3117
|
+
resuming: visibleResuming,
|
|
2929
3118
|
error: visibleError,
|
|
2930
3119
|
available,
|
|
2931
3120
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -825,7 +825,14 @@ export interface Query {
|
|
|
825
825
|
|
|
826
826
|
export interface QueryResult<T> {
|
|
827
827
|
data: T[];
|
|
828
|
+
/**
|
|
829
|
+
* True only until the first result for the bound table lands. A changed
|
|
830
|
+
* query on the same table, `refetch()` and pull-to-refresh keep the rows
|
|
831
|
+
* already in `data` with `loading: false`. Show a spinner on this only.
|
|
832
|
+
*/
|
|
828
833
|
loading: boolean;
|
|
834
|
+
/** True while any request is in flight, including a background re-fetch. */
|
|
835
|
+
fetching: boolean;
|
|
829
836
|
error: Error | null;
|
|
830
837
|
refetch(): Promise<void>;
|
|
831
838
|
}
|
|
@@ -1042,8 +1049,10 @@ export function useTheme(): ThemeTokens;
|
|
|
1042
1049
|
export function useWidgetStyle(): Record<string, unknown>;
|
|
1043
1050
|
|
|
1044
1051
|
/**
|
|
1045
|
-
* Stateful single-record fetch hook. Returns `{ data, loading,
|
|
1046
|
-
* refetch }`. `data` is one row or `null` (never an array).
|
|
1052
|
+
* Stateful single-record fetch hook. Returns `{ data, loading, fetching,
|
|
1053
|
+
* error, refetch }`. `data` is one row or `null` (never an array). Re-reading
|
|
1054
|
+
* the same record keeps it in `data` with `loading: false`; a different table
|
|
1055
|
+
* or id resets to `loading: true`.
|
|
1047
1056
|
*/
|
|
1048
1057
|
export function useDatastoreRecord(
|
|
1049
1058
|
tableId: string | null | undefined,
|
|
@@ -1051,6 +1060,7 @@ export function useDatastoreRecord(
|
|
|
1051
1060
|
): {
|
|
1052
1061
|
data: unknown | null;
|
|
1053
1062
|
loading: boolean;
|
|
1063
|
+
fetching: boolean;
|
|
1054
1064
|
error: DatastoreError | null;
|
|
1055
1065
|
refetch(): Promise<void>;
|
|
1056
1066
|
};
|
|
@@ -1245,11 +1255,21 @@ export interface AgentApi {
|
|
|
1245
1255
|
* "cancel", and show no error when the user presses it.
|
|
1246
1256
|
*/
|
|
1247
1257
|
cancel(): void;
|
|
1248
|
-
/**
|
|
1258
|
+
/**
|
|
1259
|
+
* Drop the open thread and clear the transcript this widget has seen. The
|
|
1260
|
+
* NEW thread is the one a later mount resumes.
|
|
1261
|
+
*/
|
|
1249
1262
|
reset(): void;
|
|
1250
1263
|
/** Oldest first. The SERVER owns the real transcript. */
|
|
1251
1264
|
messages: AgentMessage[];
|
|
1252
1265
|
sending: boolean;
|
|
1266
|
+
/**
|
|
1267
|
+
* sc-8867 — true only while the caller's most recent thread is being
|
|
1268
|
+
* reopened on mount. Render a loading state: an empty thread that is about
|
|
1269
|
+
* to fill reads as a lost conversation. Never true for a turn in flight —
|
|
1270
|
+
* that is `sending`.
|
|
1271
|
+
*/
|
|
1272
|
+
resuming: boolean;
|
|
1253
1273
|
error: AgentError | null;
|
|
1254
1274
|
/** False when no agent is bound, or the host brokers no agents client. */
|
|
1255
1275
|
available: boolean;
|
|
@@ -1257,9 +1277,13 @@ export interface AgentApi {
|
|
|
1257
1277
|
|
|
1258
1278
|
/**
|
|
1259
1279
|
* sc-7991 — hold a conversation with a workspace-authored AI agent.
|
|
1260
|
-
* IMPERATIVE:
|
|
1261
|
-
* widget sends ONE message; the system prompt and the real
|
|
1262
|
-
* server-side and are never readable or settable from here.
|
|
1280
|
+
* Sending is IMPERATIVE: no PAID call is made on mount, so an unused widget
|
|
1281
|
+
* costs nothing. The widget sends ONE message; the system prompt and the real
|
|
1282
|
+
* transcript stay server-side and are never readable or settable from here.
|
|
1283
|
+
*
|
|
1284
|
+
* sc-8867 — on mount it DOES reopen the caller's most recent thread with this
|
|
1285
|
+
* agent (two read-only GETs, no credit spent), so a refresh or a navigation
|
|
1286
|
+
* keeps the conversation rather than starting a blank one.
|
|
1263
1287
|
*/
|
|
1264
1288
|
export function useAgent(agentRef: string | null | undefined): AgentApi;
|
|
1265
1289
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.161.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",
|