@colixsystems/widget-sdk 0.160.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 CHANGED
@@ -48,7 +48,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
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) |
@@ -75,6 +75,19 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
75
75
 
76
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
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
+
78
91
  ### What's new in 0.160.0 (contract 1.123.0)
79
92
 
80
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`.
@@ -201,7 +214,7 @@ It presents inside `<Overlay>`, so it escapes the widget's clipping box on both
201
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.
202
215
 
203
216
  ```js
204
- const { send, reset, messages, sending, error, available } = useAgent(props.agentId);
217
+ const { send, reset, messages, sending, resuming, error, available } = useAgent(props.agentId);
205
218
  ```
206
219
 
207
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
@@ -1315,6 +1315,11 @@ const HOOKS = [
1315
1315
  "agents (an author-written name + system prompt). IMPERATIVE: call " +
1316
1316
  "send(text) from an event handler, never on mount — the first send " +
1317
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. " +
1318
1323
  "`messages` is the running transcript, oldest first, each " +
1319
1324
  "{ id, role, content, created_at } with role 'user' | 'assistant'. " +
1320
1325
  "An assistant message that used a tool also carries `activity`: what " +
@@ -1332,7 +1337,8 @@ const HOOKS = [
1332
1337
  "`agentRef` property — when it is missing, or the host brokers no " +
1333
1338
  "agents client, `available` is false and send() rejects UNAVAILABLE; " +
1334
1339
  "render a visible unbound state, NEVER a blank box. reset() drops the " +
1335
- "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. " +
1336
1342
  "Every send spends the workspace's AI CREDITS and is rate-limited per " +
1337
1343
  "caller AND per workspace, so call it once per user action. " +
1338
1344
  "AgentError.code distinguishes the refusals that share a status: " +
@@ -1352,6 +1358,8 @@ const HOOKS = [
1352
1358
  reset: "() => void",
1353
1359
  messages: "Array<{ id, conversation_id, role, content, created_at }>",
1354
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",
1355
1363
  error: "AgentError | null",
1356
1364
  available: "boolean // false when no agent is bound, or the host brokers none",
1357
1365
  },
@@ -4349,7 +4357,7 @@ const CONTRACT = deepFreeze({
4349
4357
  // 1.123.0: behaviour (sc-8595) — useDatastoreQuery / useDatastoreRecord
4350
4358
  // keep their data while a changed query or refetch runs; `loading` only
4351
4359
  // until the first result for the table/record, new `fetching` flag.
4352
- version: "1.123.0",
4360
+ version: "1.124.0",
4353
4361
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4354
4362
  hooks: HOOKS,
4355
4363
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1315,6 +1315,11 @@ const HOOKS = [
1315
1315
  "agents (an author-written name + system prompt). IMPERATIVE: call " +
1316
1316
  "send(text) from an event handler, never on mount — the first send " +
1317
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. " +
1318
1323
  "`messages` is the running transcript, oldest first, each " +
1319
1324
  "{ id, role, content, created_at } with role 'user' | 'assistant'. " +
1320
1325
  "An assistant message that used a tool also carries `activity`: what " +
@@ -1332,7 +1337,8 @@ const HOOKS = [
1332
1337
  "`agentRef` property — when it is missing, or the host brokers no " +
1333
1338
  "agents client, `available` is false and send() rejects UNAVAILABLE; " +
1334
1339
  "render a visible unbound state, NEVER a blank box. reset() drops the " +
1335
- "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. " +
1336
1342
  "Every send spends the workspace's AI CREDITS and is rate-limited per " +
1337
1343
  "caller AND per workspace, so call it once per user action. " +
1338
1344
  "AgentError.code distinguishes the refusals that share a status: " +
@@ -1352,6 +1358,8 @@ const HOOKS = [
1352
1358
  reset: "() => void",
1353
1359
  messages: "Array<{ id, conversation_id, role, content, created_at }>",
1354
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",
1355
1363
  error: "AgentError | null",
1356
1364
  available: "boolean // false when no agent is bound, or the host brokers none",
1357
1365
  },
@@ -4345,7 +4353,7 @@ const CONTRACT = deepFreeze({
4345
4353
  // 1.123.0: behaviour (sc-8595) — useDatastoreQuery / useDatastoreRecord
4346
4354
  // keep their data while a changed query or refetch runs; `loading` only
4347
4355
  // until the first result for the table/record, new `fetching` flag.
4348
- version: "1.123.0",
4356
+ version: "1.124.0",
4349
4357
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4350
4358
  hooks: HOOKS,
4351
4359
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -2813,13 +2813,19 @@ function toAgentError(err) {
2813
2813
 
2814
2814
  /**
2815
2815
  * sc-7991 — hold a conversation with a workspace-authored AI agent.
2816
- * Returns `{ send, reset, messages, sending, error, available }`.
2816
+ * Returns `{ send, cancel, reset, messages, sending, resuming, error, available }`.
2817
2817
  *
2818
2818
  * send(text) → Promise<{ role, content }> the agent's reply
2819
2819
  *
2820
- * The hook is IMPERATIVE — it NEVER fires on mount; the widget calls `send`
2821
- * from an event handler. The first `send` opens the conversation, so an author
2822
- * 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.
2823
2829
  *
2824
2830
  * `messages` is the running transcript this widget has seen, oldest first,
2825
2831
  * each `{ id, role, content, created_at }` with `role` of "user" | "assistant".
@@ -2860,12 +2866,25 @@ export function useAgent(agentRef) {
2860
2866
  const [messages, setMessages] = useState([]);
2861
2867
  const [sending, setSending] = useState(false);
2862
2868
  const [error, setError] = useState(null);
2869
+ const [resuming, setResuming] = useState(available);
2863
2870
 
2864
2871
  // Set during render when the binding changes; consumed below. A setState in
2865
2872
  // the render body would be a cascading re-render, so the flag is applied to
2866
2873
  // the value this render returns instead.
2867
2874
  const pendingResetRef = useRef(false);
2868
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
+
2869
2888
  // A changed binding is a different agent, so neither the open thread nor the
2870
2889
  // transcript carries — leaving the messages would render agent A's
2871
2890
  // conversation underneath agent B.
@@ -2874,14 +2893,25 @@ export function useAgent(agentRef) {
2874
2893
  boundRef.current = agentRef;
2875
2894
  conversationRef.current = null;
2876
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;
2877
2903
  }
2878
2904
 
2879
2905
  const abortRef = useRef(null);
2880
2906
 
2881
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;
2882
2911
  conversationRef.current = null;
2883
2912
  setMessages([]);
2884
2913
  setError(null);
2914
+ setResuming(false);
2885
2915
  }, []);
2886
2916
 
2887
2917
  // Ends the WAIT, not the turn: the abort never reaches the server, which
@@ -2891,6 +2921,104 @@ export function useAgent(agentRef) {
2891
2921
  if (controller) controller.abort();
2892
2922
  }, []);
2893
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
+
2894
3022
  const send = useCallback(async (text) => {
2895
3023
  const client = clientRef.current;
2896
3024
  const agentId = agentIdRef.current;
@@ -2962,6 +3090,7 @@ export function useAgent(agentRef) {
2962
3090
  // than one frame of the previous agent's conversation.
2963
3091
  let visibleMessages = messages;
2964
3092
  let visibleError = error;
3093
+ let visibleResuming = resuming;
2965
3094
  if (pendingResetRef.current) {
2966
3095
  pendingResetRef.current = false;
2967
3096
  visibleMessages = [];
@@ -2970,6 +3099,10 @@ export function useAgent(agentRef) {
2970
3099
  // report a healthy agent as broken, and widgets gate on error.code.
2971
3100
  visibleError = null;
2972
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);
2973
3106
  }
2974
3107
 
2975
3108
  return {
@@ -2978,6 +3111,10 @@ export function useAgent(agentRef) {
2978
3111
  reset,
2979
3112
  messages: visibleMessages,
2980
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,
2981
3118
  error: visibleError,
2982
3119
  available,
2983
3120
  };
package/dist/index.d.ts CHANGED
@@ -1255,11 +1255,21 @@ export interface AgentApi {
1255
1255
  * "cancel", and show no error when the user presses it.
1256
1256
  */
1257
1257
  cancel(): void;
1258
- /** Drop the open thread and clear the transcript this widget has seen. */
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
+ */
1259
1262
  reset(): void;
1260
1263
  /** Oldest first. The SERVER owns the real transcript. */
1261
1264
  messages: AgentMessage[];
1262
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;
1263
1273
  error: AgentError | null;
1264
1274
  /** False when no agent is bound, or the host brokers no agents client. */
1265
1275
  available: boolean;
@@ -1267,9 +1277,13 @@ export interface AgentApi {
1267
1277
 
1268
1278
  /**
1269
1279
  * sc-7991 — hold a conversation with a workspace-authored AI agent.
1270
- * IMPERATIVE: never fires on mount, so an unused widget costs nothing. The
1271
- * widget sends ONE message; the system prompt and the real transcript stay
1272
- * 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.
1273
1287
  */
1274
1288
  export function useAgent(agentRef: string | null | undefined): AgentApi;
1275
1289
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.160.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",