@lotics/app-sdk 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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`.
@@ -183,15 +187,24 @@ Two of the most common cells render the platform way with zero hand-rolling; han
183
187
  ## Previewing files
184
188
 
185
189
  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.
190
+ hand-roll per-type rendering, and don't fall back to `openExternal` for a preview (that's "download /
191
+ open elsewhere", not viewing).
192
+
193
+ - **`FileGalleryModal`** (full-screen viewer: toolbar = filename · counter · ⋯actions · close-✕, plus
194
+ prev/next + ESC) delegates to **`FilePreview`** (single-file), dispatching by MIME; the frontend
195
+ gallery uses the same renderer. Wire `onFilePress` → a `number|null` `activeIndex`.
196
+ - **PDF renders INLINE** — bytes fetched and painted to a canvas (with a selectable text layer) via
197
+ `pdfjs-dist`, NOT a native `<iframe>` viewer: a nested PDF browsing context is blocked inside the
198
+ sandboxed, cross-origin app iframe; a canvas isn't, so preview works. `pdfjs-dist` ships in the app
199
+ starter (lazy-loaded — non-PDF apps pay no bundle cost).
190
200
  - File cells already carry presigned URLs — decode with **`readFiles`** → `AppFile[]`, map to
191
201
  `DisplayFile` (`mime_type`→`mimeType`, `thumbnail_url`→`thumbnailUrl`). No round-trip, no URL
192
202
  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`.
203
+ - PDF (`pdfjs-dist`), Word (`@lotics/docx`), and Excel/CSV (`@lotics/xlsx`) all ship in the app starter,
204
+ lazy-imported — an app that never previews a type pays no bundle cost. Excel renders to a canvas with
205
+ live formula recalc; Word renders OOXML to the DOM. `@lotics/ui` is i18n/analytics-free: pass `labels`
206
+ + an `onError`. The toolbar's "open in new tab" can't pop a window in the sandbox — pass `onOpenExternal`
207
+ wired to the SDK's `openExternal` (omit it and the action hides).
195
208
 
196
209
  ## Composable optional filters (one query, many scopes)
197
210
 
@@ -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
package/dist/src/rpc.d.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * app → host: { id, op, payload }
19
19
  * host → app: { id, type: "result", data } | { id, type: "error", message }
20
20
  */
21
- export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "upload" | "members" | "context" | "openExternal" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
21
+ export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
22
22
  /** Payload for starting a streaming agent run. */
23
23
  export interface AgentRunPayload {
24
24
  alias: string;
@@ -64,4 +64,4 @@ export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T
64
64
  * ends. Bridged: the host opens the SSE with its session and forwards chunks;
65
65
  * standalone: the SDK reads the public endpoint's body directly.
66
66
  */
67
- export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: string) => void): AgentRunHandle;
67
+ export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: string) => void, onRunId?: (runId: string) => void): AgentRunHandle;
package/dist/src/rpc.js CHANGED
@@ -48,11 +48,15 @@ function ensureListener() {
48
48
  handler.reject(new Error(msg.message ?? "RPC failed"));
49
49
  return;
50
50
  }
51
- // Streaming op (agentRun): chunk* → end | error.
51
+ // Streaming op (agentRun): run-id? chunk* → end | error.
52
52
  const stream = streaming.get(msg.id);
53
53
  if (!stream)
54
54
  return;
55
- if (msg.type === "stream-chunk") {
55
+ if (msg.type === "run-id") {
56
+ if (typeof msg.runId === "string")
57
+ stream.onRunId?.(msg.runId);
58
+ }
59
+ else if (msg.type === "stream-chunk") {
56
60
  if (typeof msg.chunk === "string")
57
61
  stream.onText(msg.chunk);
58
62
  }
@@ -81,17 +85,19 @@ function rpcBridged(op, payload, host) {
81
85
  * ends. Bridged: the host opens the SSE with its session and forwards chunks;
82
86
  * standalone: the SDK reads the public endpoint's body directly.
83
87
  */
84
- export function rpcAgentRun(payload, onText) {
88
+ export function rpcAgentRun(payload, onText, onRunId) {
85
89
  const hostOrigin = getHostOrigin();
86
- return hostOrigin ? agentRunBridged(payload, onText, hostOrigin) : agentRunStandalone(payload, onText);
90
+ return hostOrigin
91
+ ? agentRunBridged(payload, onText, hostOrigin, onRunId)
92
+ : agentRunStandalone(payload, onText, onRunId);
87
93
  }
88
- function agentRunBridged(payload, onText, host) {
94
+ function agentRunBridged(payload, onText, host, onRunId) {
89
95
  ensureListener();
90
96
  const id = nextRpcId++;
91
97
  let settleDone = () => { };
92
98
  const done = new Promise((resolve, reject) => {
93
99
  settleDone = resolve;
94
- streaming.set(id, { onText, resolve, reject });
100
+ streaming.set(id, { onText, onRunId, resolve, reject });
95
101
  window.parent.postMessage({ id, op: "agentRun", payload }, host);
96
102
  });
97
103
  return {
@@ -107,7 +113,7 @@ function agentRunBridged(payload, onText, host) {
107
113
  },
108
114
  };
109
115
  }
110
- function agentRunStandalone(payload, onText) {
116
+ function agentRunStandalone(payload, onText, onRunId) {
111
117
  const controller = new AbortController();
112
118
  const done = (async () => {
113
119
  const { app_id } = await boot();
@@ -124,6 +130,9 @@ function agentRunStandalone(payload, onText) {
124
130
  const text = await res.text().catch(() => "");
125
131
  throw new Error(text || `HTTP ${res.status}`);
126
132
  }
133
+ const runId = res.headers.get("x-app-agent-run-id");
134
+ if (runId)
135
+ onRunId?.(runId);
127
136
  const reader = res.body.getReader();
128
137
  const decoder = new TextDecoder();
129
138
  try {
@@ -307,6 +316,10 @@ function rpcStandalone(op, payload) {
307
316
  return standaloneWorkflow(payload);
308
317
  case "agentRuns":
309
318
  return standaloneAgentRuns(payload);
319
+ case "agentRun.get":
320
+ return standaloneAgentRunGet(payload);
321
+ case "agentRun.cancel":
322
+ return standaloneAgentRunCancel(payload);
310
323
  case "upload":
311
324
  return standaloneUpload(payload.file);
312
325
  case "members":
@@ -402,6 +415,24 @@ async function standaloneAgentRuns(p) {
402
415
  }));
403
416
  return { runs: r.runs ?? [] };
404
417
  }
418
+ /** Poll read for a single run — follows a run to completion after its stream
419
+ * connection drops. The caller types the run shape via `rpc<T>`. */
420
+ async function standaloneAgentRunGet(p) {
421
+ const { app_id } = await boot();
422
+ const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}`, undefined, {
423
+ appId: app_id,
424
+ }));
425
+ return { run: r.run };
426
+ }
427
+ /** Request server-side cancellation of an in-flight run (an explicit user stop,
428
+ * distinct from merely closing the stream). */
429
+ async function standaloneAgentRunCancel(p) {
430
+ const { app_id } = await boot();
431
+ await apiCall("POST", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}/cancel`, {}, {
432
+ appId: app_id,
433
+ });
434
+ return { ok: true };
435
+ }
405
436
  async function standaloneUpload(file) {
406
437
  if (!(file instanceof File)) {
407
438
  throw new Error("upload payload must include a File");
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.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {