@lotics/app-sdk 0.37.0 → 0.38.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 CHANGED
@@ -53,7 +53,11 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
53
53
  data→UI adapter; the SDK never imports `@lotics/ui`) for
54
54
  `<FileThumbnail file={{ id, filename, mimeType, url }} uploading={f.status === "uploading"} />`;
55
55
  `sendDisabled` gates on `uploading` and the send payload is `fileIds`. Don't hand-roll
56
- `createObjectURL`/upload/revoke per app.
56
+ `createObjectURL`/upload/revoke per app. For a full add-files SCREEN (not the composer pill), map
57
+ each `AttachedFile` to a `FileUpload` (ready → `{ status: "complete", id, file }`, else
58
+ `{ status, id, filename, mimeType: mime_type, previewUrl: preview_url }`) and feed `@lotics/ui`
59
+ `FileGrid`'s `uploads` — it renders the uploading/error/retry tiles itself (see the kit AGENTS.md
60
+ Attachments recipe).
57
61
  - **List members** — **`useMembers(opts?)`** → `{ members: {id,name,email,image}[], loading, error }`
58
62
  — the candidate set for an assign/picker. Gated: the app must declare a `member`-typed input
59
63
  (`opts.group` restricts to a declared group). Render with `@lotics/ui` `MemberSelect` / `MemberChip`.
@@ -70,6 +74,10 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
70
74
  client-side advisory — pass `coords` to a workflow to record where an action happened.
71
75
  - **Recents** — **`useRecents(key, max)`** → most-recent-first, deduped, capped, `localStorage`-backed
72
76
  (web-only, which is why it's here, not in `@lotics/ui`). Feeds a `Combobox`'s `recentOptions`.
77
+ - **Shareable view-state (filters in the URL)** — **`useUrlState(shape)`** → `[values, setValues]`,
78
+ keeping a declared slice of state (filters, search, sort, the active tab) in the **address bar** so a
79
+ view survives refresh and is shareable/bookmarkable; `setValues(patch, { push })` makes it
80
+ back/forward-navigable. Build `shape` from **`urlParam`** codecs. See *Shareable filters in the URL*.
73
81
  - **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
74
82
  query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
75
83
  - **Infra** — **`mount(opts?)`** boots the app + analytics (call once at entry; PostHog is automatic,
@@ -140,8 +148,13 @@ compose these, don't hand-roll search:
140
148
  - **`useRecents`** — persist the picked option; pass its list as `recentOptions`.
141
149
 
142
150
  Fetch detail on select with a SECOND parameterized query (a unique-code `equals`, or a link
143
- `has_any_of [record_id]`) — never a bare full-table load. Filtering a record by its *own* id isn't a
144
- compilable AST shape; filter by a unique field value.
151
+ `has_any_of [record_id]`) — never a bare full-table load. To fetch a record by its **own id**, use the
152
+ field-less **`record_id` system condition** (a sibling of `locked`/`current_member`, NOT a `field_key`):
153
+ `useQuery(alias, {}, { filter: { node_type: "condition", type: "record_id", operator: "is_any_of", value:
154
+ [id] } })` (`is_none_of` excludes). Works on any row-level query; a *grouped* query collapses rows so it's
155
+ rejected there. A link `has_any_of [record_id]` matches a *related* record; `record_id` matches the row's
156
+ own id — the only way, since a record has no field holding its own id. **Prefer a link/join when an actual
157
+ relationship exists**; reach for `record_id` only when your starting point is a bare id (e.g. a drill row).
145
158
 
146
159
  ## Browse + sort + filter (the record picker)
147
160
 
@@ -183,15 +196,24 @@ Two of the most common cells render the platform way with zero hand-rolling; han
183
196
  ## Previewing files
184
197
 
185
198
  Render any uploaded file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`; never
186
- hand-roll per-type rendering.
187
-
188
- - **`FileGalleryModal`** (full-screen viewer) delegates to **`FilePreview`** (single-file), dispatching
189
- by MIME; the frontend gallery uses the same renderer.
199
+ hand-roll per-type rendering, and don't fall back to `openExternal` for a preview (that's "download /
200
+ open elsewhere", not viewing).
201
+
202
+ - **`FileGalleryModal`** (full-screen viewer: toolbar = filename · counter · ⋯actions · close-✕, plus
203
+ prev/next + ESC) delegates to **`FilePreview`** (single-file), dispatching by MIME; the frontend
204
+ gallery uses the same renderer. Wire `onFilePress` → a `number|null` `activeIndex`.
205
+ - **PDF renders INLINE** — bytes fetched and painted to a canvas (with a selectable text layer) via
206
+ `pdfjs-dist`, NOT a native `<iframe>` viewer: a nested PDF browsing context is blocked inside the
207
+ sandboxed, cross-origin app iframe; a canvas isn't, so preview works. `pdfjs-dist` ships in the app
208
+ starter (lazy-loaded — non-PDF apps pay no bundle cost).
190
209
  - File cells already carry presigned URLs — decode with **`readFiles`** → `AppFile[]`, map to
191
210
  `DisplayFile` (`mime_type`→`mimeType`, `thumbnail_url`→`thumbnailUrl`). No round-trip, no URL
192
211
  derivation, no server-side doc→PDF (rendering is client-side).
193
- - Word/Excel preview lazy-imports `@lotics/docx` + `@lotics/xlsx` (optional peer deps) an image/PDF
194
- app installs neither. `@lotics/ui` is i18n/analytics-free: pass `labels` + an `onError`.
212
+ - PDF (`pdfjs-dist`), Word (`@lotics/docx`), and Excel/CSV (`@lotics/xlsx`) all ship in the app starter,
213
+ lazy-imported — an app that never previews a type pays no bundle cost. Excel renders to a canvas with
214
+ live formula recalc; Word renders OOXML to the DOM. `@lotics/ui` is i18n/analytics-free: pass `labels`
215
+ + an `onError`. The toolbar's "open in new tab" can't pop a window in the sandbox — pass `onOpenExternal`
216
+ wired to the SDK's `openExternal` (omit it and the action hides).
195
217
 
196
218
  ## Composable optional filters (one query, many scopes)
197
219
 
@@ -228,6 +250,36 @@ stops constraining instead of erroring (no-op for all-required queries).
228
250
  independently. Keep a scope that must always apply `required` (a missing required param 400s). Typos
229
251
  can't widen — deploy rejects a `{{params.x}}` with no declared param.
230
252
 
253
+ ## Shareable filters in the URL (`useUrlState`)
254
+
255
+ The filters above are the query's *server* params; **`useUrlState`** is their *client* home — keep them in
256
+ the address bar and a filtered view survives refresh, is shareable/bookmarkable as a link, and (with
257
+ `{ push: true }`) is back/forward-navigable. The hook drives the host's URL over the bridge; the app
258
+ can't touch the cross-origin host URL itself. Standalone apps drive their own URL — same code.
259
+
260
+ ```tsx
261
+ const [filters, setFilters] = useUrlState({
262
+ q: urlParam.string.withDefault(""),
263
+ status: urlParam.enum(["open", "won", "lost"]), // optional → omitted when unset
264
+ tags: urlParam.arrayOf(urlParam.string).withDefault([]),
265
+ page: urlParam.number.withDefault(1),
266
+ });
267
+ // filters → { q: string; status?: "open"|"won"|"lost"; tags: string[]; page: number }
268
+ const { rows } = usePaginatedQuery("search", { keyword: filters.q, status: filters.status }, { pageSize: 50 });
269
+ setFilters({ status: "won" }); // merge, replace history → URL ?status=won
270
+ setFilters({ page: 2 }, { push: true }); // back-able navigation
271
+ ```
272
+
273
+ - **`urlParam`** codecs: `string` / `number` / `boolean` / `isoDate` / `enum([...])` / `arrayOf(inner)`,
274
+ each `.withDefault(v)` to make it required. **Defaults are omitted from the URL** (`page=1` never
275
+ appears) — links carry only what changed.
276
+ - **The URL is the only store** — `filters` is decoded fresh each render; don't mirror it into `useState`.
277
+ - **Declared keys only.** The app touches just the keys in `shape`; every other param (a second
278
+ `useUrlState`, the framework's own) is preserved on write — no namespace rule to remember.
279
+ - **Search box:** keep the live input in local `useState` and commit to `setFilters` on a **debounce** —
280
+ in an embedded app each `setFilters` is a cross-frame write. Default is *replace* (no history entry);
281
+ reserve `{ push: true }` for committed navigations (open a detail, switch a major tab).
282
+
231
283
  ---
232
284
 
233
285
  ## Recipes (app actions beyond the hooks)
@@ -414,7 +414,12 @@ export interface UseAgentRun<TInput, TOutput> {
414
414
  * structured output (undefined for a free-text or failed run). Calling again
415
415
  * aborts any run still in flight. */
416
416
  run: (input: TInput, opts: AgentRunOptions) => Promise<TOutput | undefined>;
417
+ /** Stop listening locally (no server effect) — the run keeps executing
418
+ * server-side and its result lands in the session history. Used on unmount. */
417
419
  abort: () => void;
420
+ /** Explicitly stop the run server-side (saves tokens) AND locally. Wire a
421
+ * user-facing "Stop" button to this, not `abort`. */
422
+ cancel: () => void;
418
423
  status: "idle" | "streaming" | "completed" | "error";
419
424
  /** The agent's streamed reasoning / answer text, accumulating live. */
420
425
  text: string;
package/dist/src/hooks.js CHANGED
@@ -351,6 +351,10 @@ export function useMembers(opts) {
351
351
  export function useAgentRun(alias) {
352
352
  const [state, setState] = useState(null);
353
353
  const handleRef = useRef(null);
354
+ // The run id of the in-flight run (reported by the transport off the response
355
+ // header). Lets the hook poll the run to completion if the stream connection
356
+ // drops, and cancel it server-side on an explicit stop.
357
+ const runIdRef = useRef(null);
354
358
  // Guards setState after unmount and aborts any in-flight run on unmount, so a
355
359
  // stream never keeps writing to a dead component (or leaks the transport).
356
360
  const mountedRef = useRef(true);
@@ -367,6 +371,7 @@ export function useAgentRun(alias) {
367
371
  }, []);
368
372
  const run = useCallback((input, opts) => {
369
373
  handleRef.current?.abort();
374
+ runIdRef.current = null;
370
375
  let acc = initialAgentRunState();
371
376
  let buffer = "";
372
377
  let aborted = false;
@@ -382,6 +387,8 @@ export function useAgentRun(alias) {
382
387
  for (const c of chunks)
383
388
  acc = reduceAgentChunk(acc, c);
384
389
  safeSetState({ ...acc });
390
+ }, (runId) => {
391
+ runIdRef.current = runId;
385
392
  });
386
393
  // Wrap abort so a stop (user or unmount) marks the run cancelled and clears
387
394
  // the partial state back to idle — `done` resolves cleanly, never an error.
@@ -406,9 +413,27 @@ export function useAgentRun(alias) {
406
413
  captureAppEvent("app_agent_run", { alias, ok: acc.status !== "error" });
407
414
  return acc.output;
408
415
  })
409
- .catch((err) => {
416
+ .catch(async (err) => {
410
417
  if (aborted)
411
418
  return undefined;
419
+ // The stream connection dropped, but the run is decoupled from it and
420
+ // keeps executing server-side. Poll the persisted run to completion and
421
+ // surface its result instead of a network error — work is never lost.
422
+ const runId = runIdRef.current;
423
+ if (runId) {
424
+ const settled = await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current);
425
+ if (settled && !aborted) {
426
+ acc =
427
+ settled.status === "completed"
428
+ ? { ...acc, status: "completed", output: settled.output ?? acc.output }
429
+ : { ...acc, status: "error", error: settled.error_message ?? "The run was stopped." };
430
+ safeSetState(acc);
431
+ captureAppEvent("app_agent_run", { alias, ok: settled.status === "completed" });
432
+ return acc.output;
433
+ }
434
+ if (aborted)
435
+ return undefined;
436
+ }
412
437
  acc = { ...acc, status: "error", error: err.message };
413
438
  safeSetState(acc);
414
439
  captureAppEvent("app_agent_run", { alias, ok: false });
@@ -416,9 +441,17 @@ export function useAgentRun(alias) {
416
441
  });
417
442
  }, [alias, safeSetState]);
418
443
  const abort = useCallback(() => handleRef.current?.abort(), []);
444
+ const cancel = useCallback(() => {
445
+ // Stop server-side too (saves tokens on an unwanted run), then locally.
446
+ const runId = runIdRef.current;
447
+ if (runId)
448
+ void rpc("agentRun.cancel", { run_id: runId }).catch(() => { });
449
+ handleRef.current?.abort();
450
+ }, []);
419
451
  return {
420
452
  run,
421
453
  abort,
454
+ cancel,
422
455
  status: state?.status ?? "idle",
423
456
  text: state?.text ?? "",
424
457
  steps: state?.steps ?? [],
@@ -426,6 +459,29 @@ export function useAgentRun(alias) {
426
459
  error: state?.error,
427
460
  };
428
461
  }
462
+ /**
463
+ * Poll a single run until it leaves `running` — the resume path when a run's
464
+ * stream connection drops mid-flight. Bounded just past the server-side max-run
465
+ * cap so a hung run can never poll forever, and bails the moment the caller
466
+ * aborts or unmounts. Returns the settled run, or null if it never settled.
467
+ */
468
+ async function pollAgentRunToSettle(runId, cancelled) {
469
+ const deadline = Date.now() + 11 * 60_000;
470
+ while (Date.now() < deadline) {
471
+ await new Promise((r) => setTimeout(r, 2500));
472
+ if (cancelled())
473
+ return null;
474
+ try {
475
+ const { run } = await rpc("agentRun.get", { run_id: runId });
476
+ if (run.status !== "running")
477
+ return run;
478
+ }
479
+ catch {
480
+ // transient read failure — keep polling until the deadline
481
+ }
482
+ }
483
+ return null;
484
+ }
429
485
  /**
430
486
  * The run history of a session, oldest-first — the persisted outputs the app
431
487
  * renders as a session log (NOT a chat: a flat list of past runs). Refetch
@@ -39,3 +39,7 @@ export { useOptimistic } from "./use_optimistic.js";
39
39
  export type { OptimisticApi } from "./use_optimistic.js";
40
40
  export { useRecents } from "./use_recents.js";
41
41
  export type { RecentsApi, RecentsOptions } from "./use_recents.js";
42
+ export { useUrlState } from "./use_url_state.js";
43
+ export type { UrlStateShape, UrlStateValues, SetUrlStateOptions } from "./use_url_state.js";
44
+ export { urlParam } from "./url_params.js";
45
+ export type { UrlParamCodec, OptionalUrlParamCodec, UrlParams, UrlParamValue, } from "./url_params.js";
package/dist/src/index.js CHANGED
@@ -27,3 +27,5 @@ export { readSelect } from "./select.js";
27
27
  export { row, readLinks, readFiles, readLocked } from "./row.js";
28
28
  export { useOptimistic } from "./use_optimistic.js";
29
29
  export { useRecents } from "./use_recents.js";
30
+ export { useUrlState } from "./use_url_state.js";
31
+ export { urlParam } from "./url_params.js";
package/dist/src/rpc.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
1
2
  /**
2
3
  * RPC bridge for a custom-code app's data operations.
3
4
  *
@@ -18,7 +19,7 @@
18
19
  * app → host: { id, op, payload }
19
20
  * host → app: { id, type: "result", data } | { id, type: "error", message }
20
21
  */
21
- export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "upload" | "members" | "context" | "openExternal" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
22
+ export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
22
23
  /** Payload for starting a streaming agent run. */
23
24
  export interface AgentRunPayload {
24
25
  alias: string;
@@ -58,10 +59,24 @@ export interface AppContext {
58
59
  comments_enabled: boolean;
59
60
  }
60
61
  export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
62
+ /** Read the current app-owned query params — bridged: ask the host; standalone:
63
+ * the page's own query string. */
64
+ export declare function getUrlParams(): Promise<UrlParams>;
65
+ /** Merge `patch` into the query string (each key set, or cleared when its value
66
+ * is `undefined`). `push` adds a history entry (back-able); otherwise it
67
+ * replaces in place — the default, so filter churn doesn't flood history. */
68
+ export declare function setUrlParams(patch: UrlParamsPatch, push: boolean): Promise<void>;
69
+ /** Synchronous best-effort snapshot for first paint. Standalone reads its own
70
+ * URL (no flash); bridged can't read the cross-origin host URL synchronously,
71
+ * so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
72
+ export declare function peekUrlParams(): UrlParams;
73
+ /** Subscribe to external query changes — back/forward, or an edited URL.
74
+ * Bridged: the host's `url-state` broadcast; standalone: `popstate`. */
75
+ export declare function subscribeUrlParams(cb: (params: UrlParams) => void): () => void;
61
76
  /**
62
77
  * Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
63
78
  * (the caller parses it via `agent_stream`); `done` settles when the stream
64
79
  * ends. Bridged: the host opens the SSE with its session and forwards chunks;
65
80
  * standalone: the SDK reads the public endpoint's body directly.
66
81
  */
67
- export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: string) => void): AgentRunHandle;
82
+ export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: string) => void, onRunId?: (runId: string) => void): AgentRunHandle;
package/dist/src/rpc.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { promptForPassword } from "./password_gate.js";
2
2
  import { runUploadPipeline } from "./upload/pipeline.js";
3
+ import { parseSearch, serializeMerge, } from "./url_params.js";
3
4
  /**
4
5
  * The embedding Lotics host's origin — present iff the app is bridged.
5
6
  *
@@ -23,8 +24,49 @@ export function rpc(op, payload) {
23
24
  ? rpcBridged(op, payload, hostOrigin)
24
25
  : rpcStandalone(op, payload);
25
26
  }
27
+ // ── URL state (the address bar as shareable view-state) ─────────────────────
28
+ //
29
+ // `useUrlState` keeps a declared slice of app state in the host's address bar so
30
+ // a filtered view survives refresh and is shareable/bookmarkable. An embedded
31
+ // app can't read or write the cross-origin host URL directly, so reads/writes
32
+ // flow over the bridge (the host drives its own router); a standalone app owns
33
+ // its top-level URL and the same ops resolve against `window.location`.
34
+ /** Read the current app-owned query params — bridged: ask the host; standalone:
35
+ * the page's own query string. */
36
+ export function getUrlParams() {
37
+ return rpc("urlState.get", {});
38
+ }
39
+ /** Merge `patch` into the query string (each key set, or cleared when its value
40
+ * is `undefined`). `push` adds a history entry (back-able); otherwise it
41
+ * replaces in place — the default, so filter churn doesn't flood history. */
42
+ export function setUrlParams(patch, push) {
43
+ return rpc("urlState.set", { params: patch, push });
44
+ }
45
+ /** Synchronous best-effort snapshot for first paint. Standalone reads its own
46
+ * URL (no flash); bridged can't read the cross-origin host URL synchronously,
47
+ * so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
48
+ export function peekUrlParams() {
49
+ return getHostOrigin() ? {} : parseSearch(window.location.search);
50
+ }
51
+ /** Subscribe to external query changes — back/forward, or an edited URL.
52
+ * Bridged: the host's `url-state` broadcast; standalone: `popstate`. */
53
+ export function subscribeUrlParams(cb) {
54
+ if (getHostOrigin()) {
55
+ ensureListener();
56
+ urlStateSubscribers.add(cb);
57
+ return () => {
58
+ urlStateSubscribers.delete(cb);
59
+ };
60
+ }
61
+ const handler = () => cb(parseSearch(window.location.search));
62
+ window.addEventListener("popstate", handler);
63
+ return () => window.removeEventListener("popstate", handler);
64
+ }
26
65
  const pending = new Map();
27
66
  const streaming = new Map();
67
+ /** `useUrlState` subscribers (bridged transport) — notified when the host pushes
68
+ * a `url-state` broadcast after an external navigation. */
69
+ const urlStateSubscribers = new Set();
28
70
  let nextRpcId = 0;
29
71
  let listenerInstalled = false;
30
72
  function ensureListener() {
@@ -36,7 +78,16 @@ function ensureListener() {
36
78
  if (event.source !== window.parent || event.origin !== getHostOrigin())
37
79
  return;
38
80
  const msg = event.data;
39
- if (!msg || typeof msg.id !== "number")
81
+ if (!msg)
82
+ return;
83
+ // Broadcast (no id): the host pushes the new query params after an external
84
+ // navigation (back/forward, an edited URL) so `useUrlState` re-hydrates.
85
+ if (msg.type === "url-state" && msg.params && typeof msg.params === "object") {
86
+ for (const cb of urlStateSubscribers)
87
+ cb(msg.params);
88
+ return;
89
+ }
90
+ if (typeof msg.id !== "number")
40
91
  return;
41
92
  // Single-response ops.
42
93
  const handler = pending.get(msg.id);
@@ -48,11 +99,15 @@ function ensureListener() {
48
99
  handler.reject(new Error(msg.message ?? "RPC failed"));
49
100
  return;
50
101
  }
51
- // Streaming op (agentRun): chunk* → end | error.
102
+ // Streaming op (agentRun): run-id? chunk* → end | error.
52
103
  const stream = streaming.get(msg.id);
53
104
  if (!stream)
54
105
  return;
55
- if (msg.type === "stream-chunk") {
106
+ if (msg.type === "run-id") {
107
+ if (typeof msg.runId === "string")
108
+ stream.onRunId?.(msg.runId);
109
+ }
110
+ else if (msg.type === "stream-chunk") {
56
111
  if (typeof msg.chunk === "string")
57
112
  stream.onText(msg.chunk);
58
113
  }
@@ -81,17 +136,19 @@ function rpcBridged(op, payload, host) {
81
136
  * ends. Bridged: the host opens the SSE with its session and forwards chunks;
82
137
  * standalone: the SDK reads the public endpoint's body directly.
83
138
  */
84
- export function rpcAgentRun(payload, onText) {
139
+ export function rpcAgentRun(payload, onText, onRunId) {
85
140
  const hostOrigin = getHostOrigin();
86
- return hostOrigin ? agentRunBridged(payload, onText, hostOrigin) : agentRunStandalone(payload, onText);
141
+ return hostOrigin
142
+ ? agentRunBridged(payload, onText, hostOrigin, onRunId)
143
+ : agentRunStandalone(payload, onText, onRunId);
87
144
  }
88
- function agentRunBridged(payload, onText, host) {
145
+ function agentRunBridged(payload, onText, host, onRunId) {
89
146
  ensureListener();
90
147
  const id = nextRpcId++;
91
148
  let settleDone = () => { };
92
149
  const done = new Promise((resolve, reject) => {
93
150
  settleDone = resolve;
94
- streaming.set(id, { onText, resolve, reject });
151
+ streaming.set(id, { onText, onRunId, resolve, reject });
95
152
  window.parent.postMessage({ id, op: "agentRun", payload }, host);
96
153
  });
97
154
  return {
@@ -107,7 +164,7 @@ function agentRunBridged(payload, onText, host) {
107
164
  },
108
165
  };
109
166
  }
110
- function agentRunStandalone(payload, onText) {
167
+ function agentRunStandalone(payload, onText, onRunId) {
111
168
  const controller = new AbortController();
112
169
  const done = (async () => {
113
170
  const { app_id } = await boot();
@@ -124,6 +181,9 @@ function agentRunStandalone(payload, onText) {
124
181
  const text = await res.text().catch(() => "");
125
182
  throw new Error(text || `HTTP ${res.status}`);
126
183
  }
184
+ const runId = res.headers.get("x-app-agent-run-id");
185
+ if (runId)
186
+ onRunId?.(runId);
127
187
  const reader = res.body.getReader();
128
188
  const decoder = new TextDecoder();
129
189
  try {
@@ -307,6 +367,10 @@ function rpcStandalone(op, payload) {
307
367
  return standaloneWorkflow(payload);
308
368
  case "agentRuns":
309
369
  return standaloneAgentRuns(payload);
370
+ case "agentRun.get":
371
+ return standaloneAgentRunGet(payload);
372
+ case "agentRun.cancel":
373
+ return standaloneAgentRunCancel(payload);
310
374
  case "upload":
311
375
  return standaloneUpload(payload.file);
312
376
  case "members":
@@ -315,6 +379,11 @@ function rpcStandalone(op, payload) {
315
379
  return standaloneContext();
316
380
  case "openExternal":
317
381
  return standaloneOpenExternal(payload);
382
+ case "urlState.get":
383
+ // Standalone is a top-level page — its own query string IS the store.
384
+ return Promise.resolve(parseSearch(window.location.search));
385
+ case "urlState.set":
386
+ return standaloneUrlStateSet(payload);
318
387
  case "comments.list":
319
388
  case "comments.create":
320
389
  case "comments.update":
@@ -363,6 +432,16 @@ function openValidatedUrl(url) {
363
432
  async function standaloneOpenExternal(p) {
364
433
  openValidatedUrl(p.url);
365
434
  }
435
+ async function standaloneUrlStateSet(p) {
436
+ const search = serializeMerge(window.location.search, p.params ?? {});
437
+ const url = window.location.pathname + (search ? `?${search}` : "") + window.location.hash;
438
+ // pushState/replaceState don't fire popstate, so subscribers aren't notified
439
+ // here — the hook updates its own state optimistically on write.
440
+ if (p.push)
441
+ window.history.pushState(null, "", url);
442
+ else
443
+ window.history.replaceState(null, "", url);
444
+ }
366
445
  async function standaloneContext() {
367
446
  const info = await resolveAppInfo();
368
447
  return {
@@ -402,6 +481,24 @@ async function standaloneAgentRuns(p) {
402
481
  }));
403
482
  return { runs: r.runs ?? [] };
404
483
  }
484
+ /** Poll read for a single run — follows a run to completion after its stream
485
+ * connection drops. The caller types the run shape via `rpc<T>`. */
486
+ async function standaloneAgentRunGet(p) {
487
+ const { app_id } = await boot();
488
+ const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}`, undefined, {
489
+ appId: app_id,
490
+ }));
491
+ return { run: r.run };
492
+ }
493
+ /** Request server-side cancellation of an in-flight run (an explicit user stop,
494
+ * distinct from merely closing the stream). */
495
+ async function standaloneAgentRunCancel(p) {
496
+ const { app_id } = await boot();
497
+ await apiCall("POST", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}/cancel`, {}, {
498
+ appId: app_id,
499
+ });
500
+ return { ok: true };
501
+ }
405
502
  async function standaloneUpload(file) {
406
503
  if (!(file instanceof File)) {
407
504
  throw new Error("upload payload must include a File");
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Typed codecs that map URL query-string values (always strings, or string
3
+ * arrays for repeated keys) to and from the typed view-state an app keeps in
4
+ * the address bar — the engine behind `useUrlState`.
5
+ *
6
+ * Two properties make the URLs clean and the round-trip lossless:
7
+ *
8
+ * - **Default-omission.** A codec built with `.withDefault(d)` encodes the
9
+ * default value to `undefined` — i.e. the key is dropped from the URL. So a
10
+ * filter at its default (`page=1`, `q=""`) never appears, and the shared
11
+ * link carries only what the user actually changed.
12
+ * - **Declared keys only.** `decodeAll`/`encodePatch` touch exactly the keys the
13
+ * app declared. Every other query param (`lotics_host`, `__mock`, a param a
14
+ * second `useUrlState` owns, a future host param) is read past and preserved
15
+ * on write — the merge is the namespace boundary, so no reserved-prefix rule
16
+ * is needed.
17
+ *
18
+ * Self-contained on purpose: the SDK publishes to npm as a bundle-free `dist/`,
19
+ * so it carries no workspace deps (no `@lotics/shared`). These are plain pure
20
+ * functions — a frontend that later wants the same value⇄query codec can import
21
+ * them from here rather than growing a second copy.
22
+ */
23
+ /** A query value as it appears in the URL: a single string, or an array for a
24
+ * repeated key (`?tag=a&tag=b`). Absent keys are simply missing from the map. */
25
+ export type UrlParamValue = string | string[];
26
+ /** The current query string decoded to a map (present keys only). */
27
+ export type UrlParams = Record<string, UrlParamValue>;
28
+ /** A write: each key set to its encoded value, or `undefined` to clear it. Only
29
+ * the keys present are touched; others in the URL are preserved (a merge). */
30
+ export type UrlParamsPatch = Record<string, UrlParamValue | undefined>;
31
+ /**
32
+ * A reversible mapping between a typed value `T` and its URL representation.
33
+ * Methods (not function-typed properties) so a concrete `UrlParamCodec<string>`
34
+ * stays assignable to `UrlParamCodec<unknown>` for the `useUrlState` codec map.
35
+ */
36
+ export interface UrlParamCodec<T> {
37
+ /** Raw query value (or `undefined` when the key is absent) → typed value. */
38
+ decode(raw: UrlParamValue | undefined): T;
39
+ /** Typed value → raw query value, or `undefined` to omit the key. */
40
+ encode(value: T): UrlParamValue | undefined;
41
+ }
42
+ /** A codec whose value is optional (absent key → `undefined`), refinable to a
43
+ * required codec with a fallback via `.withDefault`. */
44
+ export interface OptionalUrlParamCodec<T> extends UrlParamCodec<T | undefined> {
45
+ /** Make the value required: an absent key decodes to `fallback`, and a value
46
+ * equal to `fallback` encodes to nothing (kept out of the URL). */
47
+ withDefault(fallback: T): UrlParamCodec<T>;
48
+ }
49
+ declare function enumCodec<const V extends readonly string[]>(values: V): OptionalUrlParamCodec<V[number]>;
50
+ declare function arrayOf<T>(inner: UrlParamCodec<T | undefined>): OptionalUrlParamCodec<T[]>;
51
+ /**
52
+ * The codec builders an app composes into a `useUrlState` shape. Each base
53
+ * builder yields an optional codec (absent key → `undefined`); add
54
+ * `.withDefault(v)` to make it required and keep the default out of the URL.
55
+ *
56
+ * useUrlState({
57
+ * q: urlParam.string.withDefault(""),
58
+ * status: urlParam.enum(["open", "won", "lost"]), // optional
59
+ * tags: urlParam.arrayOf(urlParam.string).withDefault([]),
60
+ * from: urlParam.isoDate, // optional Date
61
+ * page: urlParam.number.withDefault(1),
62
+ * })
63
+ */
64
+ export declare const urlParam: {
65
+ readonly string: OptionalUrlParamCodec<string>;
66
+ readonly number: OptionalUrlParamCodec<number>;
67
+ readonly boolean: OptionalUrlParamCodec<boolean>;
68
+ readonly isoDate: OptionalUrlParamCodec<Date>;
69
+ readonly enum: typeof enumCodec;
70
+ readonly arrayOf: typeof arrayOf;
71
+ };
72
+ /** Decode the declared keys out of a params map into typed values. */
73
+ export declare function decodeAll<D extends Record<string, UrlParamCodec<unknown>>>(defs: D, params: UrlParams): {
74
+ [K in keyof D]: D[K] extends UrlParamCodec<infer T> ? T : never;
75
+ };
76
+ /** Encode a partial set of declared values into a patch (each key → value or
77
+ * `undefined` to clear). Only the keys present in `values` are emitted, so the
78
+ * write merges and leaves every other query param untouched. */
79
+ export declare function encodePatch<D extends Record<string, UrlParamCodec<unknown>>>(defs: D, values: Partial<{
80
+ [K in keyof D]: D[K] extends UrlParamCodec<infer T> ? T : never;
81
+ }>): UrlParamsPatch;
82
+ /** Parse a `location.search` string into a params map (repeated keys → array). */
83
+ export declare function parseSearch(search: string): UrlParams;
84
+ /** Apply a patch to a `location.search` string and return the new query string
85
+ * (no leading `?`). Each patch key is replaced; `undefined` clears it; every
86
+ * other existing param is preserved. */
87
+ export declare function serializeMerge(search: string, patch: UrlParamsPatch): string;
88
+ /** Apply a patch to an in-memory params map (the optimistic local mirror). */
89
+ export declare function applyPatch(params: UrlParams, patch: UrlParamsPatch): UrlParams;
90
+ /** Shallow value-equality over two params maps — used to drop echoed updates so
91
+ * an app's own write doesn't re-render it a second time. */
92
+ export declare function paramsEqual(a: UrlParams, b: UrlParams): boolean;
93
+ export {};
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Typed codecs that map URL query-string values (always strings, or string
3
+ * arrays for repeated keys) to and from the typed view-state an app keeps in
4
+ * the address bar — the engine behind `useUrlState`.
5
+ *
6
+ * Two properties make the URLs clean and the round-trip lossless:
7
+ *
8
+ * - **Default-omission.** A codec built with `.withDefault(d)` encodes the
9
+ * default value to `undefined` — i.e. the key is dropped from the URL. So a
10
+ * filter at its default (`page=1`, `q=""`) never appears, and the shared
11
+ * link carries only what the user actually changed.
12
+ * - **Declared keys only.** `decodeAll`/`encodePatch` touch exactly the keys the
13
+ * app declared. Every other query param (`lotics_host`, `__mock`, a param a
14
+ * second `useUrlState` owns, a future host param) is read past and preserved
15
+ * on write — the merge is the namespace boundary, so no reserved-prefix rule
16
+ * is needed.
17
+ *
18
+ * Self-contained on purpose: the SDK publishes to npm as a bundle-free `dist/`,
19
+ * so it carries no workspace deps (no `@lotics/shared`). These are plain pure
20
+ * functions — a frontend that later wants the same value⇄query codec can import
21
+ * them from here rather than growing a second copy.
22
+ */
23
+ function first(raw) {
24
+ return Array.isArray(raw) ? raw[0] : raw;
25
+ }
26
+ function valueEqual(a, b) {
27
+ if (a === b)
28
+ return true;
29
+ if (a instanceof Date && b instanceof Date)
30
+ return a.getTime() === b.getTime();
31
+ if (Array.isArray(a) && Array.isArray(b)) {
32
+ return a.length === b.length && a.every((x, i) => valueEqual(x, b[i]));
33
+ }
34
+ return false;
35
+ }
36
+ /** Wrap an optional base codec with `.withDefault`. */
37
+ function optional(base) {
38
+ return {
39
+ decode: base.decode,
40
+ encode: base.encode,
41
+ withDefault(fallback) {
42
+ return {
43
+ decode: (raw) => {
44
+ const v = base.decode(raw);
45
+ return v === undefined ? fallback : v;
46
+ },
47
+ encode: (value) => (valueEqual(value, fallback) ? undefined : base.encode(value)),
48
+ };
49
+ },
50
+ };
51
+ }
52
+ const stringCodec = optional({
53
+ decode: (raw) => (raw === undefined ? undefined : first(raw)),
54
+ encode: (v) => v,
55
+ });
56
+ const numberCodec = optional({
57
+ decode: (raw) => {
58
+ const s = first(raw);
59
+ if (s === undefined || s === "")
60
+ return undefined;
61
+ const n = Number(s);
62
+ return Number.isFinite(n) ? n : undefined;
63
+ },
64
+ encode: (v) => (v === undefined ? undefined : String(v)),
65
+ });
66
+ const booleanCodec = optional({
67
+ decode: (raw) => {
68
+ const s = first(raw);
69
+ return s === "true" ? true : s === "false" ? false : undefined;
70
+ },
71
+ encode: (v) => (v === undefined ? undefined : v ? "true" : "false"),
72
+ });
73
+ const isoDateCodec = optional({
74
+ decode: (raw) => {
75
+ const s = first(raw);
76
+ if (s === undefined || !/^\d{4}-\d{2}-\d{2}$/.test(s))
77
+ return undefined;
78
+ const d = new Date(`${s}T00:00:00.000Z`);
79
+ return Number.isNaN(d.getTime()) ? undefined : d;
80
+ },
81
+ encode: (v) => (v === undefined ? undefined : v.toISOString().slice(0, 10)),
82
+ });
83
+ function enumCodec(values) {
84
+ return optional({
85
+ decode: (raw) => {
86
+ const s = first(raw);
87
+ return s === undefined ? undefined : values.find((v) => v === s);
88
+ },
89
+ encode: (v) => v,
90
+ });
91
+ }
92
+ function arrayOf(inner) {
93
+ return optional({
94
+ decode: (raw) => {
95
+ if (raw === undefined)
96
+ return undefined;
97
+ const items = Array.isArray(raw) ? raw : [raw];
98
+ return items.map((x) => inner.decode(x)).filter((x) => x !== undefined);
99
+ },
100
+ encode: (value) => {
101
+ if (value === undefined || value.length === 0)
102
+ return undefined;
103
+ const out = value
104
+ .map((x) => inner.encode(x))
105
+ .filter((x) => typeof x === "string");
106
+ return out.length > 0 ? out : undefined;
107
+ },
108
+ });
109
+ }
110
+ /**
111
+ * The codec builders an app composes into a `useUrlState` shape. Each base
112
+ * builder yields an optional codec (absent key → `undefined`); add
113
+ * `.withDefault(v)` to make it required and keep the default out of the URL.
114
+ *
115
+ * useUrlState({
116
+ * q: urlParam.string.withDefault(""),
117
+ * status: urlParam.enum(["open", "won", "lost"]), // optional
118
+ * tags: urlParam.arrayOf(urlParam.string).withDefault([]),
119
+ * from: urlParam.isoDate, // optional Date
120
+ * page: urlParam.number.withDefault(1),
121
+ * })
122
+ */
123
+ export const urlParam = {
124
+ string: stringCodec,
125
+ number: numberCodec,
126
+ boolean: booleanCodec,
127
+ isoDate: isoDateCodec,
128
+ enum: enumCodec,
129
+ arrayOf,
130
+ };
131
+ /** Decode the declared keys out of a params map into typed values. */
132
+ export function decodeAll(defs, params) {
133
+ const out = {};
134
+ for (const key of Object.keys(defs)) {
135
+ out[key] = defs[key].decode(params[key]);
136
+ }
137
+ // Boundary: a per-key loop can't be expressed as the mapped result type.
138
+ return out;
139
+ }
140
+ /** Encode a partial set of declared values into a patch (each key → value or
141
+ * `undefined` to clear). Only the keys present in `values` are emitted, so the
142
+ * write merges and leaves every other query param untouched. */
143
+ export function encodePatch(defs, values) {
144
+ const out = {};
145
+ for (const key of Object.keys(values)) {
146
+ const codec = defs[key];
147
+ if (codec)
148
+ out[key] = codec.encode(values[key]);
149
+ }
150
+ return out;
151
+ }
152
+ /** Parse a `location.search` string into a params map (repeated keys → array). */
153
+ export function parseSearch(search) {
154
+ const sp = new URLSearchParams(search);
155
+ const out = {};
156
+ for (const key of sp.keys()) {
157
+ if (key in out)
158
+ continue;
159
+ const all = sp.getAll(key);
160
+ out[key] = all.length > 1 ? all : all[0];
161
+ }
162
+ return out;
163
+ }
164
+ /** Apply a patch to a `location.search` string and return the new query string
165
+ * (no leading `?`). Each patch key is replaced; `undefined` clears it; every
166
+ * other existing param is preserved. */
167
+ export function serializeMerge(search, patch) {
168
+ const sp = new URLSearchParams(search);
169
+ for (const [key, value] of Object.entries(patch)) {
170
+ sp.delete(key);
171
+ if (value === undefined)
172
+ continue;
173
+ if (Array.isArray(value)) {
174
+ for (const item of value)
175
+ sp.append(key, item);
176
+ }
177
+ else {
178
+ sp.append(key, value);
179
+ }
180
+ }
181
+ return sp.toString();
182
+ }
183
+ /** Apply a patch to an in-memory params map (the optimistic local mirror). */
184
+ export function applyPatch(params, patch) {
185
+ const next = { ...params };
186
+ for (const [key, value] of Object.entries(patch)) {
187
+ if (value === undefined)
188
+ delete next[key];
189
+ else
190
+ next[key] = value;
191
+ }
192
+ return next;
193
+ }
194
+ /** Shallow value-equality over two params maps — used to drop echoed updates so
195
+ * an app's own write doesn't re-render it a second time. */
196
+ export function paramsEqual(a, b) {
197
+ const ak = Object.keys(a);
198
+ const bk = Object.keys(b);
199
+ if (ak.length !== bk.length)
200
+ return false;
201
+ for (const key of ak) {
202
+ const av = a[key];
203
+ const bv = b[key];
204
+ if (Array.isArray(av) || Array.isArray(bv)) {
205
+ if (!Array.isArray(av) || !Array.isArray(bv))
206
+ return false;
207
+ if (av.length !== bv.length || !av.every((x, i) => x === bv[i]))
208
+ return false;
209
+ }
210
+ else if (av !== bv) {
211
+ return false;
212
+ }
213
+ }
214
+ return true;
215
+ }
@@ -0,0 +1,16 @@
1
+ import { type UrlParamCodec } from "./url_params.js";
2
+ /** A `useUrlState` shape: a map of param key → codec. */
3
+ export type UrlStateShape = Record<string, UrlParamCodec<unknown>>;
4
+ /** The decoded, typed values for a shape. */
5
+ export type UrlStateValues<D extends UrlStateShape> = {
6
+ [K in keyof D]: D[K] extends UrlParamCodec<infer T> ? T : never;
7
+ };
8
+ export interface SetUrlStateOptions {
9
+ /** Add a browser history entry (back/forward navigable). Default `false` —
10
+ * replace in place, so high-frequency filter changes don't flood history. */
11
+ push?: boolean;
12
+ }
13
+ export declare function useUrlState<D extends UrlStateShape>(defs: D): readonly [
14
+ UrlStateValues<D>,
15
+ (patch: Partial<UrlStateValues<D>>, options?: SetUrlStateOptions) => void
16
+ ];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Keep a declared slice of an app's view-state — filters, search, sort, the
3
+ * active tab — in the browser's address bar, with two-way sync. The result is
4
+ * real browser behaviour for an otherwise opaque cross-origin app: a filtered
5
+ * view survives refresh, is shareable/bookmarkable as a link, and (with
6
+ * `{ push: true }`) is back/forward-navigable.
7
+ *
8
+ * const [filters, setFilters] = useUrlState({
9
+ * q: urlParam.string.withDefault(""),
10
+ * status: urlParam.enum(["open", "won", "lost"]), // optional
11
+ * tags: urlParam.arrayOf(urlParam.string).withDefault([]),
12
+ * page: urlParam.number.withDefault(1),
13
+ * });
14
+ * // filters → { q: string; status?: "open"|"won"|"lost"; tags: string[]; page: number }
15
+ * setFilters({ q: "acme" }); // merge, replace history
16
+ * setFilters({ status: "won" }, { push: true }); // back-able navigation
17
+ *
18
+ * The app declares only the keys it owns; every other query param (a param a
19
+ * second `useUrlState` owns, the framework's `lotics_host`/`__mock`, a future
20
+ * host param) is read past and preserved on write. For a search box, keep the
21
+ * live input in local state and commit to `setFilters` on a debounce — each
22
+ * call is a cross-frame write in an embedded app.
23
+ *
24
+ * The address bar is the only store: nothing is persisted server-side. Values
25
+ * are decoded fresh from the current params each render; standalone reads the
26
+ * URL directly, while an embedded app keeps a local mirror the SDK syncs from
27
+ * the host (initial `urlState.get`, the app's own writes, and back/forward
28
+ * broadcasts) — so there's no independently-mutable second copy to drift.
29
+ */
30
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
31
+ import { getUrlParams, peekUrlParams, setUrlParams, subscribeUrlParams } from "./rpc.js";
32
+ import { applyPatch, decodeAll, encodePatch, paramsEqual, } from "./url_params.js";
33
+ export function useUrlState(defs) {
34
+ // `defs` is expected stable (declared inline once); read through a ref so the
35
+ // decoded values and `setValues` stay referentially stable across renders.
36
+ const defsRef = useRef(defs);
37
+ defsRef.current = defs;
38
+ const [params, setParams] = useState(peekUrlParams);
39
+ // Once the user edits, a late initial hydration (bridged `getUrlParams`
40
+ // resolves a tick after mount) must not clobber their write.
41
+ const editedRef = useRef(false);
42
+ useEffect(() => {
43
+ let active = true;
44
+ const apply = (next) => {
45
+ if (active)
46
+ setParams((prev) => (paramsEqual(prev, next) ? prev : next));
47
+ };
48
+ void getUrlParams().then((p) => {
49
+ if (active && !editedRef.current)
50
+ apply(p);
51
+ });
52
+ const unsubscribe = subscribeUrlParams(apply);
53
+ return () => {
54
+ active = false;
55
+ unsubscribe();
56
+ };
57
+ }, []);
58
+ const values = useMemo(() => decodeAll(defsRef.current, params), [params]);
59
+ const setValues = useCallback((patch, options) => {
60
+ editedRef.current = true;
61
+ const encoded = encodePatch(defsRef.current, patch);
62
+ // Optimistic local mirror so the UI is responsive even before the write
63
+ // round-trips (and the only update path in standalone, where the history
64
+ // write fires no event).
65
+ setParams((prev) => applyPatch(prev, encoded));
66
+ // The optimistic mirror already reflects the change, so a failed write
67
+ // only loses cross-refresh persistence, never the session — keep the
68
+ // optimistic state, but surface the failure instead of swallowing it.
69
+ void setUrlParams(encoded, options?.push ?? false).catch((err) => {
70
+ console.error("useUrlState: failed to write state to the address bar", err);
71
+ });
72
+ }, []);
73
+ return [values, setValues];
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.37.0",
3
+ "version": "0.38.1",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {