@lotics/app-sdk 0.100.1 → 0.101.1
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/AGENTS.md +32 -47
- package/dist/agent_stream.d.ts +131 -0
- package/dist/ask_ai.d.ts +27 -0
- package/dist/attachments.d.ts +58 -0
- package/dist/chunk-ARV5FAU5.js +1132 -0
- package/dist/comments.d.ts +89 -0
- package/dist/error_report.d.ts +9 -0
- package/dist/folder_pick.d.ts +8 -0
- package/dist/geolocation.d.ts +42 -0
- package/dist/hooks.d.ts +251 -0
- package/dist/{src/index.d.ts → index.d.ts} +13 -22
- package/dist/index.js +31331 -0
- package/dist/index.js.LEGAL.txt +11 -0
- package/dist/members.d.ts +32 -0
- package/dist/mock.d.ts +37 -0
- package/dist/mount.d.ts +19 -0
- package/dist/new_record.d.ts +37 -0
- package/dist/open_app.d.ts +12 -0
- package/dist/open_external.d.ts +10 -0
- package/dist/overlay.d.ts +25 -0
- package/dist/queries.d.ts +231 -0
- package/dist/recording.d.ts +47 -0
- package/dist/recording_state.d.ts +43 -0
- package/dist/rename_file.d.ts +13 -0
- package/dist/router.d.ts +10 -0
- package/dist/router.js +97 -0
- package/dist/row.d.ts +87 -0
- package/dist/rpc.d.ts +114 -0
- package/dist/select.d.ts +24 -0
- package/dist/shared_types.d.ts +8 -0
- package/dist/store.d.ts +43 -0
- package/dist/types.d.ts +36 -0
- package/dist/upload/optimize.d.ts +30 -0
- package/dist/upload/pipeline.d.ts +36 -0
- package/dist/upload/transport.d.ts +19 -0
- package/dist/url_params.d.ts +55 -0
- package/dist/use_recents.d.ts +15 -0
- package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
- package/dist/viewer.d.ts +41 -0
- package/dist/written.d.ts +79 -0
- package/docs/ai.md +74 -133
- package/docs/data_fetching.md +209 -290
- package/docs/files.md +61 -51
- package/docs/members_and_options.md +92 -62
- package/docs/mutations.md +136 -205
- package/docs/navigation_and_state.md +26 -35
- package/docs/queries.md +144 -207
- package/docs/recipes.md +21 -45
- package/docs/runtime.md +74 -137
- package/docs/security.md +8 -11
- package/docs/workflows.md +189 -174
- package/package.json +27 -28
- package/dist/src/agent_stream.d.ts +0 -200
- package/dist/src/agent_stream.js +0 -314
- package/dist/src/ask_ai.d.ts +0 -40
- package/dist/src/ask_ai.js +0 -35
- package/dist/src/attachments.d.ts +0 -68
- package/dist/src/attachments.js +0 -93
- package/dist/src/comments.d.ts +0 -127
- package/dist/src/comments.js +0 -192
- package/dist/src/download.js +0 -54
- package/dist/src/geolocation.d.ts +0 -64
- package/dist/src/geolocation.js +0 -96
- package/dist/src/hooks.d.ts +0 -781
- package/dist/src/hooks.js +0 -860
- package/dist/src/index.js +0 -34
- package/dist/src/members.d.ts +0 -105
- package/dist/src/members.js +0 -62
- package/dist/src/mock.d.ts +0 -118
- package/dist/src/mock.js +0 -124
- package/dist/src/mount.d.ts +0 -47
- package/dist/src/mount.js +0 -34
- package/dist/src/new_record.d.ts +0 -74
- package/dist/src/new_record.js +0 -117
- package/dist/src/open_app.d.ts +0 -15
- package/dist/src/open_app.js +0 -18
- package/dist/src/open_external.d.ts +0 -16
- package/dist/src/open_external.js +0 -19
- package/dist/src/recording.d.ts +0 -59
- package/dist/src/recording.js +0 -30
- package/dist/src/recording_state.d.ts +0 -59
- package/dist/src/recording_state.js +0 -94
- package/dist/src/router.d.ts +0 -17
- package/dist/src/router.js +0 -144
- package/dist/src/row.d.ts +0 -159
- package/dist/src/row.js +0 -254
- package/dist/src/rpc.d.ts +0 -207
- package/dist/src/rpc.js +0 -904
- package/dist/src/select.d.ts +0 -48
- package/dist/src/select.js +0 -40
- package/dist/src/types.d.ts +0 -115
- package/dist/src/types.js +0 -1
- package/dist/src/upload/optimize.d.ts +0 -54
- package/dist/src/upload/optimize.js +0 -207
- package/dist/src/upload/pipeline.d.ts +0 -55
- package/dist/src/upload/pipeline.js +0 -52
- package/dist/src/upload/transport.d.ts +0 -42
- package/dist/src/upload/transport.js +0 -128
- package/dist/src/url_params.d.ts +0 -93
- package/dist/src/url_params.js +0 -215
- package/dist/src/use_optimistic.d.ts +0 -27
- package/dist/src/use_optimistic.js +0 -27
- package/dist/src/use_recents.d.ts +0 -19
- package/dist/src/use_recents.js +0 -71
- package/dist/src/use_url_state.js +0 -73
- package/dist/src/viewer.d.ts +0 -26
- package/dist/src/viewer.js +0 -47
- /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
|
-
}
|