@lotics/app-sdk 0.100.0 → 0.101.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.
Files changed (108) hide show
  1. package/AGENTS.md +32 -47
  2. package/dist/agent_stream.d.ts +131 -0
  3. package/dist/ask_ai.d.ts +27 -0
  4. package/dist/attachments.d.ts +58 -0
  5. package/dist/chunk-ARV5FAU5.js +1132 -0
  6. package/dist/comments.d.ts +89 -0
  7. package/dist/error_report.d.ts +9 -0
  8. package/dist/folder_pick.d.ts +8 -0
  9. package/dist/geolocation.d.ts +42 -0
  10. package/dist/hooks.d.ts +251 -0
  11. package/dist/{src/index.d.ts → index.d.ts} +13 -22
  12. package/dist/index.js +31309 -0
  13. package/dist/index.js.LEGAL.txt +11 -0
  14. package/dist/members.d.ts +32 -0
  15. package/dist/mock.d.ts +37 -0
  16. package/dist/mount.d.ts +19 -0
  17. package/dist/new_record.d.ts +37 -0
  18. package/dist/open_app.d.ts +12 -0
  19. package/dist/open_external.d.ts +10 -0
  20. package/dist/overlay.d.ts +25 -0
  21. package/dist/queries.d.ts +231 -0
  22. package/dist/recording.d.ts +47 -0
  23. package/dist/recording_state.d.ts +43 -0
  24. package/dist/rename_file.d.ts +13 -0
  25. package/dist/router.d.ts +10 -0
  26. package/dist/router.js +97 -0
  27. package/dist/row.d.ts +87 -0
  28. package/dist/rpc.d.ts +114 -0
  29. package/dist/select.d.ts +24 -0
  30. package/dist/shared_types.d.ts +8 -0
  31. package/dist/store.d.ts +43 -0
  32. package/dist/types.d.ts +36 -0
  33. package/dist/upload/optimize.d.ts +30 -0
  34. package/dist/upload/pipeline.d.ts +36 -0
  35. package/dist/upload/transport.d.ts +19 -0
  36. package/dist/url_params.d.ts +55 -0
  37. package/dist/use_recents.d.ts +15 -0
  38. package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
  39. package/dist/viewer.d.ts +41 -0
  40. package/dist/written.d.ts +77 -0
  41. package/docs/ai.md +74 -133
  42. package/docs/data_fetching.md +209 -290
  43. package/docs/files.md +61 -51
  44. package/docs/members_and_options.md +93 -63
  45. package/docs/mutations.md +135 -205
  46. package/docs/navigation_and_state.md +26 -35
  47. package/docs/queries.md +144 -207
  48. package/docs/recipes.md +21 -45
  49. package/docs/runtime.md +74 -137
  50. package/docs/security.md +8 -11
  51. package/docs/workflows.md +189 -174
  52. package/package.json +27 -28
  53. package/dist/src/agent_stream.d.ts +0 -200
  54. package/dist/src/agent_stream.js +0 -314
  55. package/dist/src/ask_ai.d.ts +0 -40
  56. package/dist/src/ask_ai.js +0 -35
  57. package/dist/src/attachments.d.ts +0 -68
  58. package/dist/src/attachments.js +0 -93
  59. package/dist/src/comments.d.ts +0 -127
  60. package/dist/src/comments.js +0 -192
  61. package/dist/src/download.js +0 -54
  62. package/dist/src/geolocation.d.ts +0 -64
  63. package/dist/src/geolocation.js +0 -96
  64. package/dist/src/hooks.d.ts +0 -781
  65. package/dist/src/hooks.js +0 -860
  66. package/dist/src/index.js +0 -34
  67. package/dist/src/members.d.ts +0 -105
  68. package/dist/src/members.js +0 -62
  69. package/dist/src/mock.d.ts +0 -118
  70. package/dist/src/mock.js +0 -124
  71. package/dist/src/mount.d.ts +0 -47
  72. package/dist/src/mount.js +0 -34
  73. package/dist/src/new_record.d.ts +0 -74
  74. package/dist/src/new_record.js +0 -117
  75. package/dist/src/open_app.d.ts +0 -15
  76. package/dist/src/open_app.js +0 -18
  77. package/dist/src/open_external.d.ts +0 -16
  78. package/dist/src/open_external.js +0 -19
  79. package/dist/src/recording.d.ts +0 -59
  80. package/dist/src/recording.js +0 -30
  81. package/dist/src/recording_state.d.ts +0 -59
  82. package/dist/src/recording_state.js +0 -94
  83. package/dist/src/router.d.ts +0 -17
  84. package/dist/src/router.js +0 -144
  85. package/dist/src/row.d.ts +0 -159
  86. package/dist/src/row.js +0 -254
  87. package/dist/src/rpc.d.ts +0 -207
  88. package/dist/src/rpc.js +0 -904
  89. package/dist/src/select.d.ts +0 -34
  90. package/dist/src/select.js +0 -40
  91. package/dist/src/types.d.ts +0 -115
  92. package/dist/src/types.js +0 -1
  93. package/dist/src/upload/optimize.d.ts +0 -54
  94. package/dist/src/upload/optimize.js +0 -207
  95. package/dist/src/upload/pipeline.d.ts +0 -55
  96. package/dist/src/upload/pipeline.js +0 -52
  97. package/dist/src/upload/transport.d.ts +0 -42
  98. package/dist/src/upload/transport.js +0 -128
  99. package/dist/src/url_params.d.ts +0 -93
  100. package/dist/src/url_params.js +0 -215
  101. package/dist/src/use_optimistic.d.ts +0 -27
  102. package/dist/src/use_optimistic.js +0 -27
  103. package/dist/src/use_recents.d.ts +0 -19
  104. package/dist/src/use_recents.js +0 -71
  105. package/dist/src/use_url_state.js +0 -73
  106. package/dist/src/viewer.d.ts +0 -26
  107. package/dist/src/viewer.js +0 -47
  108. /package/dist/{src/download.d.ts → download.d.ts} +0 -0
package/dist/src/hooks.js DELETED
@@ -1,860 +0,0 @@
1
- /**
2
- * Typed React hooks for Lotics app data access.
3
- *
4
- * Reads come in three shapes — `useQuery` (single fetch), `useInfiniteQuery`
5
- * (append / load-more), and `usePaginatedQuery` (numbered pages + total) — and
6
- * every mutation is a `useWorkflow`; `useFileUpload` attaches files. App code
7
- * never writes records directly — all writes flow through declared workflows,
8
- * which gives the app owner a typed, audited chokepoint and means a
9
- * publicly-shared app exposes no anonymous direct-write path. An uploaded file
10
- * is inert until a workflow attaches it, so file upload keeps that same property.
11
- *
12
- * Every hook is a thin wrapper over the postMessage RPC bridge — the parent
13
- * does the actual API calls, results stream back through `rpc()`. The read
14
- * hooks cache through SWR keyed by (alias, params, …): reads dedupe and the
15
- * cache survives unmount/remount. Mutation / member hooks keep their own local
16
- * `useState` — they have nothing to share.
17
- */
18
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
- import { DEFAULT_IMAGE_FIDELITY } from "./upload/optimize.js";
20
- import { useAttachmentQueue } from "./attachments.js";
21
- import useSWR from "swr";
22
- import useSWRInfinite from "swr/infinite";
23
- import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeQueriesChanged, registerQueryAlias, notifyLocalWrite, } from "./rpc.js";
24
- import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, pendingInteractiveCall, buildChoiceOutput, applyInteractiveAnswer, landingOf, proseOf, ABORTED, } from "./agent_stream.js";
25
- import { getMockRows, getMockWorkflow, hasMockFlag } from "./mock.js";
26
- export { buildChoiceOutput } from "./agent_stream.js";
27
- export function useWorkflow(alias) {
28
- return useCallback(async (inputs) => {
29
- // Checked per CALL, not per render: a fixture registered after mount or
30
- // swapped by HMR is picked up, and an app that never runs this workflow
31
- // does no work here. Mock mode needs BOTH the `?__mock=1` flag and a
32
- // registered fixture, so a bundle carrying demo data cannot answer real
33
- // traffic with it.
34
- const mock = getMockWorkflow(alias);
35
- if (mock) {
36
- return typeof mock === "function" ? await mock(inputs ?? {}) : mock;
37
- }
38
- const result = await rpc("workflow", { alias, inputs: inputs ?? {} });
39
- // A workflow is the app's only write path, so a successful one means the
40
- // rows on screen are stale. Re-read now rather than waiting for the host's
41
- // realtime push to say what the caller already knows — that wait is what
42
- // makes a screen feel like it lagged its own button.
43
- //
44
- // Only on success: a refused write changed nothing, and refetching after
45
- // one would replace the values the user is still looking at (and about to
46
- // correct) with an identical set, for a round trip nobody asked for.
47
- if (result.status !== "error")
48
- notifyLocalWrite();
49
- return result;
50
- }, [alias]);
51
- }
52
- // Shared SWR config: surface a failed query immediately, keep the last good
53
- // rows (no retry loop that masks the error), and honor the focus/reconnect
54
- // opt-out.
55
- //
56
- // `revalidateIfStale` is LEFT AT SWR's DEFAULT (`true`): re-mounting a screen
57
- // renders its cached rows AND re-reads them. Pinning it `false` saved a re-query
58
- // per navigation and cost every screen a hand-rolled arrival refetch — where
59
- // forgetting is SILENT (no error, no empty state, just last visit's data until a
60
- // hard reload), which is not a mistake an author gets told about once and learns.
61
- //
62
- // The default costs one BACKGROUND request per re-mount of a warm key and
63
- // nothing visible: SWR's `isLoading` is true only on an initial load with no
64
- // cached data, so nothing blanks to a skeleton, and `dedupingInterval` collapses
65
- // a burst of navigation into one fetch.
66
- //
67
- // `useAppContext` (viewer.ts) still pins it off — the app's identity is read
68
- // once at boot and cannot change under the reader. `useInfiniteQuery` is the
69
- // one hook this does not reach; see its own note.
70
- function swrConfig(revalidateOnFocus) {
71
- return {
72
- revalidateOnFocus,
73
- revalidateOnReconnect: revalidateOnFocus,
74
- shouldRetryOnError: false,
75
- // A query's KEY carries its params, filter and sort, so anything derived
76
- // from the rows on screen re-keys the moment they change. Without this SWR
77
- // answers a new key with `undefined`, `rows` falls to `[]`, and every list
78
- // renders empty for a frame — unmounting each row's images, which remount
79
- // blank. That is the behaviour the `loading` contract above already
80
- // promises against, and what the product app sets globally for the same
81
- // reason.
82
- keepPreviousData: true,
83
- };
84
- }
85
- /**
86
- * The fixture rows for this alias and this call, held STABLE across renders.
87
- *
88
- * A function fixture returns a fresh array each time it is called, and `rows` is
89
- * handed straight to consumers — so without memoizing, a screen that derives
90
- * anything from `rows` (a `useMemo`, an effect) re-runs on every render, and one
91
- * that sets state from them never settles. Keyed exactly the way the real read
92
- * is keyed, so a changed param/filter/sort re-asks the fixture and nothing else
93
- * does.
94
- */
95
- function useMockRows(alias, call) {
96
- const key = JSON.stringify([call.params, call.filter ?? null, call.sort ?? null, call.limit ?? null]);
97
- const { params, filter, sort, limit } = call;
98
- return useMemo(() => getMockRows(alias, { params, filter, sort, limit }),
99
- // The KEY, not the object literal the caller rebuilt this render — a memo on
100
- // the literal would be no memo at all.
101
- // eslint-disable-next-line react-hooks/exhaustive-deps
102
- [alias, key]);
103
- }
104
- /**
105
- * Refetch this query when the host reports that ITS tables changed — whoever
106
- * wrote them: another member, a workflow, the chat agent, or an external agent
107
- * over the CLI or MCP.
108
- *
109
- * The host names the affected aliases rather than telling every hook to
110
- * re-read: an app's queries usually span several tables, and a blanket refetch
111
- * would re-run expensive aggregates on a change to a table they never touch.
112
- *
113
- * Inert in mock mode (no host, no listener), for a mocked alias (its rows come
114
- * from the fixture), and for a standalone/public app (no host to push).
115
- * Shared by all three query hooks.
116
- */
117
- function useHostRefetch(alias, refetch, mockRows) {
118
- useEffect(() => {
119
- if (hasMockFlag() || mockRows)
120
- return;
121
- // Registered separately from the subscription because the two answer
122
- // different questions: the subscription is "tell me when MY alias moved",
123
- // the registration is "I am on screen, so a local write should re-read me".
124
- const unregister = registerQueryAlias(alias);
125
- const unsubscribe = subscribeQueriesChanged((aliases) => {
126
- if (aliases.includes(alias))
127
- refetch();
128
- });
129
- return () => {
130
- unregister();
131
- unsubscribe();
132
- };
133
- }, [alias, refetch, mockRows]);
134
- }
135
- /**
136
- * THE COUNT READ, defined once — used by `usePaginatedQuery` for its page total
137
- * and by `useCount` for a bare number.
138
- *
139
- * Both must agree on the SWR key and on the request body, byte for byte: that
140
- * identity is what makes a table and a badge over the same set buy ONE count
141
- * between them. Two hand-copied literals would hold today and diverge the first
142
- * time one is edited, and the failure is silent — nothing breaks, the screen
143
- * just quietly issues two full scans where it used to issue one. So there is one
144
- * definition and no opportunity to disagree.
145
- *
146
- * The key omits PAGE and SORT deliberately: a count is independent of both, so
147
- * page clicks and re-sorts reuse it. `active: false` yields a null key, and SWR
148
- * issues nothing for a null key — which is how a caller-supplied total, a
149
- * disabled hook and a fixture all suppress the request.
150
- */
151
- function useCountRead(alias, params, filter, active, revalidateOnFocus) {
152
- const key = active
153
- ? ["app-query-count", alias, params ?? {}, filter ?? null]
154
- : null;
155
- return useSWR(key, () => rpc("query", { alias, params: params ?? {}, filter, count: true }), swrConfig(revalidateOnFocus));
156
- }
157
- export function useQuery(alias, params, opts) {
158
- const pageSize = opts?.pageSize;
159
- const enabled = opts?.enabled ?? true;
160
- const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
161
- const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
162
- const filter = opts?.filter;
163
- const mockRows = useMockRows(alias, { params: params ?? {}, filter, sort, limit: pageSize });
164
- // A `null` key disables the fetch (mock / disabled). SWR canonicalizes the
165
- // params/sort/filter objects via its stable hash, so identical reads dedupe to
166
- // one request + one cache entry that survives unmount/remount.
167
- const key = mockRows || !enabled
168
- ? null
169
- : ["app-query", alias, params ?? {}, pageSize ?? null, sort ?? null, filter ?? null];
170
- const swr = useSWR(key, () => rpc("query", {
171
- alias,
172
- params: params ?? {},
173
- limit: pageSize,
174
- offset: 0,
175
- sort,
176
- filter,
177
- }), swrConfig(revalidateOnFocus));
178
- const refetch = useCallback(() => {
179
- void swr.mutate();
180
- }, [swr]);
181
- useHostRefetch(alias, refetch, mockRows);
182
- return {
183
- rows: mockRows ?? swr.data?.rows ?? [],
184
- // A fixture IS the whole set, so it is never short; before the first
185
- // response there is nothing to be short of.
186
- truncated: mockRows || !swr.data ? false : swr.data.truncated,
187
- loading: mockRows ? false : swr.isLoading,
188
- isValidating: mockRows ? false : swr.isValidating,
189
- error: swr.error ? swr.error.message : null,
190
- refetch,
191
- };
192
- }
193
- export function useFieldOptions(alias, opts) {
194
- const enabled = opts?.enabled ?? true;
195
- // Field config is slow-changing, so no focus/reconnect revalidation — the app
196
- // calls `refetch()` after a known change. A null key defers the fetch.
197
- const key = enabled ? ["app-field-options", alias] : null;
198
- const swr = useSWR(key, () => rpc("field_options", { alias }), swrConfig(false));
199
- const fields = useMemo(() => {
200
- const raw = swr.data?.fields ?? {};
201
- const out = {};
202
- for (const [col, def] of Object.entries(raw)) {
203
- const options = def.options ?? [];
204
- const index = new Map(options.map((o) => [o.key, o]));
205
- out[col] = { label: def.label, options, byKey: (k) => index.get(k) };
206
- }
207
- return out;
208
- }, [swr.data]);
209
- const refetch = useCallback(() => {
210
- void swr.mutate();
211
- }, [swr]);
212
- return {
213
- fields,
214
- loading: swr.isLoading,
215
- isValidating: swr.isValidating,
216
- error: swr.error ? swr.error.message : null,
217
- refetch,
218
- };
219
- }
220
- export function useInfiniteQuery(alias, params, opts) {
221
- const pageSize = opts?.pageSize ?? 30;
222
- const enabled = opts?.enabled ?? true;
223
- const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
224
- const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
225
- const filter = opts?.filter;
226
- const mockRows = useMockRows(alias, { params: params ?? {}, filter, sort, limit: pageSize });
227
- // Keyset (seek) pagination: each page seeks past the previous page's
228
- // `next_cursor` instead of an increasing OFFSET, so deep scrolls stay O(page)
229
- // and never skip/duplicate a row as the set shifts. The cursor is opaque; the
230
- // server keysets a sortable key (the query's own order when `sort` is unset,
231
- // else the id) and falls back to offset transparently. `loadMore`/`rows` are
232
- // unchanged — the cursor is internal.
233
- const getKey = (index, prev) => {
234
- if (mockRows || !enabled)
235
- return null;
236
- // Stop once a page reports no next cursor (the end).
237
- if (index > 0 && (prev == null || prev.next_cursor == null))
238
- return null;
239
- const cursor = index === 0 ? null : (prev?.next_cursor ?? null);
240
- return ["app-query-infinite", alias, params ?? {}, pageSize, sort ?? null, filter ?? null, cursor];
241
- };
242
- const swr = useSWRInfinite(getKey, (key) => {
243
- const cursor = key[6];
244
- return rpc("query", {
245
- alias,
246
- params: params ?? {},
247
- limit: pageSize,
248
- keyset: true,
249
- cursor: cursor ?? undefined,
250
- sort,
251
- filter,
252
- });
253
- },
254
- // `revalidateFirstPage: false` — `loadMore()` fetches the next page and only
255
- // that page. SWR's default re-fetches page 1 on every load-more, costing a
256
- // request per scroll and reshuffling the top of the feed under a reader
257
- // looking further down it (pinned by the keyset test).
258
- //
259
- // THE COST, stated because it is otherwise invisible: this hook does not
260
- // arrival-revalidate the way the other two do. SWRInfinite decides per page
261
- // and never consults `revalidateIfStale`, and neither lever that would change
262
- // that is cheap — `revalidateFirstPage` pays per load-more, `revalidateOnMount`
263
- // refetches EVERY loaded page. A cold key still fetches, focus/reconnect and
264
- // `refetch()` still refresh; a feed that must be fresh on arrival calls
265
- // `refetch()`.
266
- { revalidateFirstPage: false, ...swrConfig(revalidateOnFocus) });
267
- // SWRInfinite leaves an in-flight page slot `undefined` until it resolves —
268
- // operate on resolved pages only so a page being appended never crashes the
269
- // flatten or skews the counts.
270
- const pages = (swr.data ?? []).filter((p) => p != null);
271
- const rows = mockRows ?? pages.flatMap((p) => p.rows);
272
- const lastPage = pages.length > 0 ? pages[pages.length - 1] : undefined;
273
- // A non-null next_cursor means another page exists; null/absent = the end.
274
- const hasMore = lastPage != null && lastPage.next_cursor != null;
275
- const loadingMore = swr.isValidating && swr.size > pages.length;
276
- const refetch = useCallback(() => {
277
- void swr.mutate();
278
- }, [swr]);
279
- useHostRefetch(alias, refetch, mockRows);
280
- const loadMore = useCallback(() => {
281
- if (!hasMore || loadingMore)
282
- return;
283
- void swr.setSize((n) => n + 1);
284
- }, [hasMore, loadingMore, swr]);
285
- return {
286
- rows,
287
- loading: mockRows ? false : swr.isLoading,
288
- isValidating: mockRows ? false : swr.isValidating,
289
- error: swr.error ? swr.error.message : null,
290
- refetch,
291
- loadMore,
292
- hasMore,
293
- loadingMore,
294
- };
295
- }
296
- export function usePaginatedQuery(alias, params, opts) {
297
- const pageSize = opts?.pageSize ?? 25;
298
- const enabled = opts?.enabled ?? true;
299
- const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
300
- const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
301
- const filter = opts?.filter;
302
- const mockRows = useMockRows(alias, { params: params ?? {}, filter, sort, limit: pageSize });
303
- // `null` = the caller owns the total and it hasn't resolved; `undefined`
304
- // (or omitted) = the hook counts. See `PaginatedQueryOptions.total`.
305
- const suppliedTotal = opts?.total;
306
- const callerOwnsTotal = suppliedTotal !== undefined;
307
- // The result-set identity. When it changes, `page` derives back to 0 (not via
308
- // an effect, so the stale page never fires a wasted fetch) and the count key
309
- // changes (recount). `setPage` re-stamps the current identity.
310
- const resetKey = JSON.stringify([params ?? {}, filter ?? null]);
311
- const [pageState, setPageState] = useState({ key: resetKey, page: 0 });
312
- const page = pageState.key === resetKey ? pageState.page : 0;
313
- const setPage = useCallback((p) => setPageState({ key: resetKey, page: Math.max(0, p) }), [resetKey]);
314
- const rowsKey = mockRows || !enabled
315
- ? null
316
- : ["app-query-page", alias, params ?? {}, pageSize, sort ?? null, filter ?? null, page];
317
- const rowsSwr = useSWR(rowsKey, () => rpc("query", {
318
- alias,
319
- params: params ?? {},
320
- limit: pageSize,
321
- offset: page * pageSize,
322
- sort,
323
- filter,
324
- }), swrConfig(revalidateOnFocus));
325
- // Not counted when the caller supplies the total, when the hook is disabled,
326
- // or under a fixture — see `useCountRead` for why the key drops page and sort.
327
- const countSwr = useCountRead(alias, params, filter, !mockRows && enabled && !callerOwnsTotal, revalidateOnFocus);
328
- const rows = mockRows ?? rowsSwr.data?.rows ?? [];
329
- // A supplied `null` reads out as `undefined` — "not known yet" is one state
330
- // to the consumer whether the hook is counting or the caller is.
331
- const total = mockRows
332
- ? mockRows.length
333
- : callerOwnsTotal
334
- ? (suppliedTotal ?? undefined)
335
- : countSwr.data?.total;
336
- const totalPages = total != null ? Math.max(1, Math.ceil(total / pageSize)) : undefined;
337
- const hasMore = total != null ? (page + 1) * pageSize < total : rows.length === pageSize;
338
- const refetch = useCallback(() => {
339
- void rowsSwr.mutate();
340
- void countSwr.mutate();
341
- }, [rowsSwr, countSwr]);
342
- useHostRefetch(alias, refetch, mockRows);
343
- return {
344
- rows,
345
- total,
346
- totalPages,
347
- page,
348
- pageSize,
349
- hasMore,
350
- setPage,
351
- loading: mockRows ? false : rowsSwr.isLoading,
352
- isValidating: mockRows ? false : rowsSwr.isValidating || countSwr.isValidating,
353
- error: rowsSwr.error?.message ?? countSwr.error?.message ?? null,
354
- refetch,
355
- };
356
- }
357
- export function useCount(alias, params, opts) {
358
- const enabled = opts?.enabled ?? true;
359
- const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
360
- const filter = opts?.filter;
361
- const mockRows = useMockRows(alias, { params: params ?? {}, filter });
362
- // The SAME read `usePaginatedQuery` uses for its total — one definition, so a
363
- // list and a badge over one set can never drift into two scans.
364
- const countSwr = useCountRead(alias, params, filter, !mockRows && enabled, revalidateOnFocus);
365
- const refetch = useCallback(() => {
366
- void countSwr.mutate();
367
- }, [countSwr]);
368
- useHostRefetch(alias, refetch, mockRows);
369
- return {
370
- // Under a fixture the whole set is the fixture, so its length IS the count —
371
- // the same substitution every other hook makes for `rows`.
372
- total: mockRows ? mockRows.length : countSwr.data?.total,
373
- loading: mockRows ? false : countSwr.isLoading,
374
- isValidating: mockRows ? false : countSwr.isValidating,
375
- error: countSwr.error?.message ?? null,
376
- refetch,
377
- };
378
- }
379
- /**
380
- * Upload files from an app. The bytes are stored via a presigned
381
- * direct-to-storage upload the host mediates; the API server never proxies
382
- * them. Works the same in a public (anonymous) app and a member-facing one.
383
- *
384
- * ```tsx
385
- * const { upload, uploading } = useFileUpload();
386
- * const submit = useWorkflow("submitApplication");
387
- * const cccd = await upload(file);
388
- * await submit({ ...fields, cccd_file_id: cccd.id });
389
- * ```
390
- */
391
- export function useFileUpload() {
392
- const [inFlight, setInFlight] = useState(0);
393
- const [error, setError] = useState(null);
394
- const upload = useCallback(async (file, options) => {
395
- const fidelity = options?.fidelity ?? DEFAULT_IMAGE_FIDELITY;
396
- setInFlight((n) => n + 1);
397
- setError(null);
398
- try {
399
- const uploaded = await rpc("upload", { file, fidelity });
400
- return uploaded;
401
- }
402
- catch (err) {
403
- const message = err instanceof Error ? err.message : "Upload failed";
404
- setError(message);
405
- throw err;
406
- }
407
- finally {
408
- setInFlight((n) => n - 1);
409
- }
410
- }, []);
411
- return { upload, uploading: inFlight > 0, error };
412
- }
413
- /**
414
- * Attachments with the optimistic-preview UX: a local object-URL preview shows
415
- * the INSTANT a file is added, the upload runs in the background, and the
416
- * stored `file_id` lands in `files` when it completes. Picking is the app's
417
- * (button / paste / drop) — pass the resulting `File[]` to `add`, or to
418
- * `attach` where a write has to wait for the ids.
419
- *
420
- * WHICH LIFECYCLE is the caller's one decision. A composer accumulates and
421
- * clears when it sends; a record's section passes what its row now holds as
422
- * `landed`, and each entry leaves the queue as the pile takes it over.
423
- *
424
- * ```tsx
425
- * const { files, add, remove, clear, uploading, fileIds } = useAttachments();
426
- * const design = useWorkflow("design");
427
- * // attach: <Button icon="paperclip" onPress={() => pickFiles({ accept: "image/*" }).then(add)} />
428
- * // preview: map each AttachedFile to a @lotics/ui DisplayFile (snake_case → camelCase) — the
429
- * // app owns this data→UI adapter; the SDK never imports @lotics/ui:
430
- * // files.map((f) => (
431
- * // <FileThumbnail
432
- * // file={{ id: f.id, filename: f.filename, mimeType: f.mime_type, url: f.preview_url }}
433
- * // uploading={f.status === "uploading"} onRemove={() => remove(f.id)} />
434
- * // ))
435
- * // send: design({ photo: fileIds[0] }); clear();
436
- * ```
437
- *
438
- * The queue is `useAttachmentQueue`, bound here to the app's own upload.
439
- */
440
- export function useAttachments(options) {
441
- const { upload } = useFileUpload();
442
- return useAttachmentQueue(upload, options);
443
- }
444
- /**
445
- * JSON-serialize the view-state snapshot — used for BOTH change-detection (a
446
- * fresh inline object each render must NOT re-post) and the wire payload. The
447
- * `data` field is app-supplied `unknown`; if it can't be JSON-serialized we drop
448
- * it and keep description + records, mirroring the host's own `data` cap (which
449
- * JSON-encodes `data` and drops it past the size limit) — a non-serializable
450
- * value must never crash the app's render.
451
- */
452
- function serializeAiContext(context) {
453
- try {
454
- return JSON.stringify(context);
455
- }
456
- catch {
457
- return JSON.stringify({ description: context.description, records: context.records });
458
- }
459
- }
460
- /**
461
- * Publish a slice of the CURRENT SCREEN's view state to the app's ambient chat
462
- * agent, so a member chatting alongside the app gets an agent that knows what
463
- * they are looking at — which list is filtered to what, which record is open,
464
- * what is typed into a form. Declarative and lifecycle-bound: mounting or
465
- * changing `context` pushes it to the host; unmounting, renaming the `slot`, or
466
- * passing `null` clears it. Independent components may publish different `slot`s
467
- * concurrently (a list screen + an open detail drawer); the newest value per
468
- * slot wins.
469
- *
470
- * **Push-only, and a SNAPSHOT of what the app already RENDERED to this member** —
471
- * never a channel for chat to pull app-authority data. `records` are passed as
472
- * raw `{ table_id, record_id }` refs (unresolved); the member's own chat agent
473
- * acts on them only where that member's IAM already allows. The host feeds
474
- * `description`/`data` into the agent's prompt as clearly-labeled DATA, never as
475
- * instructions.
476
- *
477
- * Host-enforced caps (exceeding them truncates/drops — never an error): `slot`
478
- * ≤ 50 chars; `description` ≤ 1000 chars (truncated with "…"); `records` ≤ 20;
479
- * `data` must JSON-serialize to ≤ 2000 chars or the `data` field is dropped (the
480
- * description is kept); ≤ 8 slots per app (a 9th evicts the least-recently
481
- * updated).
482
- *
483
- * ```tsx
484
- * useAiContext("orders_list", {
485
- * description: `Viewing ${rows.length} orders filtered to status=open, sorted by due date.`,
486
- * // `flatMap` + the guard, not `map`: the addressing columns are absent on a
487
- * // GROUPED query's rows, so a ref built without checking carries `undefined`
488
- * // and points the agent at nothing.
489
- * records: rows.flatMap((r) =>
490
- * r.__source_table_id && r.__source_record_id
491
- * ? [{ table_id: r.__source_table_id, record_id: r.__source_record_id }]
492
- * : [],
493
- * ),
494
- * data: { filter: "status=open", sort: "due_date desc" },
495
- * });
496
- * ```
497
- *
498
- * No-ops with no embedding host (standalone on the app's own origin — there is no
499
- * chat surface to inform) and in mock mode. Since 0.52.
500
- */
501
- export function useAiContext(slot, context) {
502
- // Serialize once for both the change key and the payload. The effect re-posts
503
- // only when the serialized value (or slot) actually changes, so a fresh inline
504
- // object each render never spams the host. Payloads are small (host-capped),
505
- // so the JSON round-trip is cheap.
506
- const serialized = context === null ? null : serializeAiContext(context);
507
- // Post the current value on mount and whenever it changes.
508
- useEffect(() => {
509
- if (hasMockFlag())
510
- return;
511
- const payload = serialized === null ? null : JSON.parse(serialized);
512
- postHostNotification({ type: "aiContext", slot, context: payload });
513
- }, [slot, serialized]);
514
- // Clear the slot on unmount or slot rename ONLY — a value-only change is
515
- // overwritten by the post above, with no intermediate clear.
516
- useEffect(() => {
517
- if (hasMockFlag())
518
- return;
519
- return () => postHostNotification({ type: "aiContext", slot, context: null });
520
- }, [slot]);
521
- }
522
- /**
523
- * List the members of the app's organization — the candidate set for an
524
- * "assign to a member" picker. Each member is `{ id, name, email, image }`
525
- * (`image` = avatar URL, may be null). Resolves through the host (member-only;
526
- * an anonymous public visitor gets an error). Names may be empty for members
527
- * without a display name set — fall back to `email`.
528
- *
529
- * Gated: the app must DECLARE that it works with members — it needs a workflow
530
- * whose manifest declares a `member`-typed input. Passing `{ group }` restricts
531
- * to that group, and is only honored if some member input declares that
532
- * `group` — so an app can only list (and assign into) groups it declares.
533
- *
534
- * ```tsx
535
- * const { members } = useMembers({ group: GRP.sale });
536
- * // <Select variant="native" options={members.map((m) => ({
537
- * // value: m.id, label: m.name || m.email || m.id, image: m.image,
538
- * // }))} />
539
- * ```
540
- */
541
- export function useMembers(opts) {
542
- const group = opts?.group;
543
- const [state, setState] = useState({
544
- members: [],
545
- loading: true,
546
- error: null,
547
- });
548
- useEffect(() => {
549
- let cancelled = false;
550
- setState((s) => ({ ...s, loading: true, error: null }));
551
- rpc("members", { group })
552
- .then((r) => {
553
- if (!cancelled)
554
- setState({ members: r.members ?? [], loading: false, error: null });
555
- })
556
- .catch((err) => {
557
- if (!cancelled)
558
- setState({ members: [], loading: false, error: err.message });
559
- });
560
- return () => {
561
- cancelled = true;
562
- };
563
- }, [group]);
564
- return state;
565
- }
566
- export function useAgentRun(alias) {
567
- const [state, setState] = useState(null);
568
- const handleRef = useRef(null);
569
- // The run id of the in-flight run (reported by the transport off the response
570
- // header). Lets the hook poll the run to completion if the stream connection
571
- // drops, and cancel it server-side on an explicit stop.
572
- const runIdRef = useRef(null);
573
- const inflightRef = useRef(null);
574
- // Guards setState after unmount and aborts any in-flight run on unmount, so a
575
- // stream never keeps writing to a dead component (or leaks the transport).
576
- const mountedRef = useRef(true);
577
- useEffect(() => {
578
- mountedRef.current = true;
579
- return () => {
580
- mountedRef.current = false;
581
- handleRef.current?.abort();
582
- };
583
- }, []);
584
- // The latest state, readable synchronously (answerChoice needs the pending
585
- // call + parts without waiting a render).
586
- const stateRef = useRef(null);
587
- const safeSetState = useCallback((s) => {
588
- stateRef.current = s;
589
- if (mountedRef.current)
590
- setState(s);
591
- }, []);
592
- /**
593
- * Drive ONE streaming leg (the initial run, or a continuation after an
594
- * answered ask) into shared state: fold SSE chunks from `initial`, and on a
595
- * cut stream fall back to the persisted row — the run is decoupled from the
596
- * connection, so a truncation polls the row instead of surfacing a network
597
- * error. A leg that ends `awaiting_input` resolves with `undefined`; the
598
- * final leg resolves with the structured output.
599
- *
600
- * `revertTo` (the continuation leg): the leg's initial state is an OPTIMISTIC
601
- * commit (the ask settled locally before the server accepted the answer). A
602
- * request that fails before ANY chunk arrives means the server rejected it and
603
- * the row never left `awaiting_input` — restore the pre-answer state so the
604
- * question is answerable again, and REJECT so the app surfaces the error.
605
- * Without this, a 400/409 would be misread as a mid-run connection drop,
606
- * poll-adopted, and silently swallowed.
607
- */
608
- const streamLeg = useCallback((start, initial, revertTo) => {
609
- let acc = initial;
610
- let buffer = "";
611
- let aborted = false;
612
- let received = false;
613
- safeSetState(acc);
614
- // Enforce the invariant the wizard relies on: `awaiting_input` always
615
- // yields an answerable pendingChoice. A polled parked row whose question
616
- // cannot be rebuilt (no part in the stream, none derivable from the row)
617
- // must fail loud and retryable, never sit as a silent dead end.
618
- const adoptGuarded = (state, settled) => {
619
- const adopted = adoptSettledRun(state, settled);
620
- if (adopted.status === "awaiting_input" && !pendingInteractiveCall(adopted)) {
621
- return {
622
- ...adopted,
623
- status: "error",
624
- error: "The run is waiting for an answer that could not be recovered. Run it again.",
625
- };
626
- }
627
- return adopted;
628
- };
629
- const handle = start((textChunk) => {
630
- if (aborted)
631
- return;
632
- buffer += textChunk;
633
- const { chunks, rest } = parseSseChunks(buffer);
634
- buffer = rest;
635
- if (chunks.length === 0)
636
- return;
637
- received = true;
638
- for (const c of chunks)
639
- acc = reduceAgentChunk(acc, c);
640
- safeSetState({ ...acc });
641
- });
642
- // Wrap abort so a stop (user or unmount) marks the run cancelled and clears
643
- // the partial state back to idle — `done` resolves cleanly, never an error.
644
- handleRef.current = {
645
- abort: () => {
646
- if (aborted)
647
- return;
648
- aborted = true;
649
- handle.abort();
650
- safeSetState(null);
651
- },
652
- };
653
- return handle.done
654
- .then(async () => {
655
- if (aborted)
656
- return ABORTED;
657
- // A clean stream end WITHOUT a `finish` frame is a truncation, not a
658
- // completion — an edge can close a long SSE gracefully mid-run (seen
659
- // in production on ~2-minute runs), swallowing the frames that carry
660
- // the structured result. The run is decoupled and settles server-side
661
- // regardless, so the row is the source of truth: poll it, exactly
662
- // like the dropped-with-error path below. (2026-07-18: four NOXH
663
- // extractions completed server-side while every client showed
664
- // failure through this hole.)
665
- if (acc.status === "streaming") {
666
- const runId = runIdRef.current;
667
- const settled = runId
668
- ? await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current)
669
- : null;
670
- if (aborted)
671
- return ABORTED;
672
- // No settled row after the full poll window means the run's status
673
- // could NOT be confirmed (an orphan the server's reaper hasn't
674
- // repaired yet, or no run id ever arrived) — that is an error, never
675
- // a fake "completed": a structured consumer reading a completed
676
- // state with no output would render success around a missing result.
677
- acc = settled
678
- ? adoptGuarded(acc, settled)
679
- : {
680
- ...acc,
681
- status: "error",
682
- error: "The run was interrupted and its result could not be confirmed. Run it again.",
683
- };
684
- safeSetState(acc);
685
- }
686
- return landingOf(acc);
687
- })
688
- .catch(async (err) => {
689
- if (aborted)
690
- return ABORTED;
691
- // The continue request itself was rejected — the server never resumed
692
- // the run (400 invalid answer, 409 raced cancel/expiry, network at
693
- // connect). Restore the pre-answer parked state and surface the error;
694
- // this is a request failure, not a stream truncation.
695
- if (revertTo && !received) {
696
- safeSetState(revertTo);
697
- throw err;
698
- }
699
- // The stream connection dropped, but the run is decoupled from it and
700
- // keeps executing server-side. Poll the persisted run to completion and
701
- // surface its result instead of a network error — work is never lost.
702
- const runId = runIdRef.current;
703
- if (runId) {
704
- const settled = await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current);
705
- if (aborted)
706
- return ABORTED;
707
- if (settled) {
708
- acc = adoptGuarded(acc, settled);
709
- safeSetState(acc);
710
- }
711
- if (settled)
712
- return landingOf(acc);
713
- }
714
- // A run failure is DATA — the landing carries it, so no consumer needs
715
- // try/catch to tell "the run died" from "the run parked". Only API
716
- // MISUSE still rejects (answering when nothing is pending).
717
- acc = { ...acc, status: "error", error: err.message };
718
- safeSetState(acc);
719
- return landingOf(acc);
720
- });
721
- }, [safeSetState]);
722
- const run = useCallback((input, opts) => {
723
- // Single-flight: an extra press must not become a second paid run — the
724
- // old abort-and-restart default kept the first run executing (and
725
- // billing) server-side while the client went blind to it. Joining the
726
- // in-flight promise makes a double-click resolve with the first run's
727
- // result; `replace: true` is the explicit opt-in to abort-and-restart.
728
- if (inflightRef.current && !opts.replace) {
729
- return inflightRef.current;
730
- }
731
- handleRef.current?.abort();
732
- runIdRef.current = null;
733
- const inflight = streamLeg((onText) => rpcAgentRun({ alias, session_id: opts.sessionId, input: input ?? {} }, onText, (runId) => {
734
- runIdRef.current = runId;
735
- }), initialAgentRunState());
736
- const tracked = inflight.finally(() => {
737
- if (inflightRef.current === tracked)
738
- inflightRef.current = null;
739
- });
740
- inflightRef.current = tracked;
741
- return tracked;
742
- }, [alias, streamLeg]);
743
- // The run's pending ask — non-null exactly while it is parked on a question.
744
- const pendingChoice = state ? pendingInteractiveCall(state) : null;
745
- const answerChoice = useCallback((answers) => {
746
- // Join first: a double-submit (the wizard's button pressed twice) must
747
- // land on the in-flight continuation, not reject on the already-settled
748
- // pending check below.
749
- if (inflightRef.current)
750
- return inflightRef.current;
751
- const current = stateRef.current;
752
- const runId = runIdRef.current;
753
- const pending = current ? pendingInteractiveCall(current) : null;
754
- if (!current || !runId || !pending) {
755
- return Promise.reject(new Error("No pending question to answer."));
756
- }
757
- const output = buildChoiceOutput(pending.questions, answers);
758
- const inflight = streamLeg((onText) => rpcAgentRunContinue({ run_id: runId, tool_call_id: pending.toolCallId, output }, onText),
759
- // The answered part settles locally (its output rides the feed's
760
- // on-demand reveal) and the state re-enters streaming for the leg.
761
- applyInteractiveAnswer(current, pending.toolCallId, output),
762
- // On a rejected request the leg restores this pre-answer state, so the
763
- // wizard reappears and the rejection reaches the app.
764
- current);
765
- const tracked = inflight.finally(() => {
766
- if (inflightRef.current === tracked)
767
- inflightRef.current = null;
768
- });
769
- inflightRef.current = tracked;
770
- return tracked;
771
- }, [streamLeg]);
772
- const abort = useCallback(() => handleRef.current?.abort(), []);
773
- const cancel = useCallback(() => {
774
- // Stop server-side too (saves tokens on an unwanted run), then locally.
775
- // No analytics event here: the cancel RPC stamps `app_agent_runs
776
- // .cancel_requested_at`, which is what separates a user's Stop from the
777
- // unmount/tab-close that `abort` also serves — a client event would only
778
- // duplicate a column, and would go stale the moment an app pins an old SDK.
779
- const runId = runIdRef.current;
780
- if (runId)
781
- void rpc("agentRun.cancel", { run_id: runId }).catch(() => { });
782
- handleRef.current?.abort();
783
- }, []);
784
- // `parts` is the source of truth; `text` (all prose concatenated) is the derived
785
- // answer view.
786
- const parts = state?.parts ?? EMPTY_PARTS;
787
- const text = useMemo(() => proseOf(parts), [parts]);
788
- return {
789
- run,
790
- abort,
791
- cancel,
792
- status: state?.status ?? "idle",
793
- parts,
794
- pendingChoice,
795
- answerChoice,
796
- text,
797
- output: state?.output,
798
- error: state?.error,
799
- };
800
- }
801
- /** Stable empty transcript so an idle hook returns a constant `parts` reference
802
- * (no new [] each render → dependents don't re-run needlessly). */
803
- const EMPTY_PARTS = [];
804
- /**
805
- * Bounded PAST the server-side max-run cap (20 min) plus settle grace. The
806
- * server guarantees a LIVE run settles by its own cap timer, so a row still
807
- * `running` at this deadline is an orphan (its process died mid-run) — the
808
- * server's reapers repair the row; the client stops polling and surfaces the
809
- * error. An earlier 11-minute bound gave up BEFORE the cap: a slow run that
810
- * lost its stream showed failure while the server later completed it — the
811
- * exact result-loss class this poll exists to prevent.
812
- */
813
- const POLL_DEADLINE_MS = 22 * 60_000;
814
- /**
815
- * Poll a single run until it leaves `running` — the resume path when a run's
816
- * stream connection drops mid-flight. Bounded just past the server-side max-run
817
- * cap so a hung run can never poll forever, and bails the moment the caller
818
- * aborts or unmounts. Returns the settled run, or null if it never settled.
819
- */
820
- async function pollAgentRunToSettle(runId, cancelled) {
821
- const deadline = Date.now() + POLL_DEADLINE_MS;
822
- while (Date.now() < deadline) {
823
- await new Promise((r) => setTimeout(r, 2500));
824
- if (cancelled())
825
- return null;
826
- try {
827
- const { run } = await rpc("agentRun.get", { run_id: runId });
828
- if (run.status !== "running")
829
- return run;
830
- }
831
- catch {
832
- // transient read failure — keep polling until the deadline
833
- }
834
- }
835
- return null;
836
- }
837
- /**
838
- * The run history of a session, oldest-first — the persisted outputs the app
839
- * renders as a session log (NOT a chat: a flat list of past runs). Refetch
840
- * after a `run(...)` completes to pull in the new one.
841
- *
842
- * ```tsx
843
- * const { runs } = useAgentRuns(sessionId);
844
- * ```
845
- */
846
- export function useAgentRuns(sessionId, opts) {
847
- const enabled = opts?.enabled ?? true;
848
- const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
849
- const key = enabled && sessionId ? ["app-agent-runs", sessionId] : null;
850
- const swr = useSWR(key, () => rpc("agentRuns", { session_id: sessionId }), swrConfig(revalidateOnFocus));
851
- const refetch = useCallback(() => {
852
- void swr.mutate();
853
- }, [swr]);
854
- return {
855
- runs: swr.data?.runs ?? [],
856
- loading: swr.isLoading,
857
- error: swr.error?.message ?? null,
858
- refetch,
859
- };
860
- }