@lotics/app-sdk 0.125.8 → 0.126.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
@@ -20,11 +20,11 @@ This file is the index. The **exact type** of anything is its shipped declaratio
20
20
  | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY reference** — the JS subset a body may use, opaque `fld_*`/`opt_*` keys, every step form, the accepted sugar, helpers, record-write surfaces, the traps and the verify loop. |
21
21
  | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
22
22
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload` and its `fidelity` and a photo's `place`, `renameFile` (a new file over the same bytes), `useAttachments`/`useAttachmentPiles`, `readFiles` and presigned URLs (a bearer credential — never logged or persisted), workflow-generated files, naming a zip's entries, the delivery bounds. |
23
- | [docs/members_and_options.md](./docs/members_and_options.md) | People, select options and comments — `useMembers`, `useFieldOptions`, `useViewer`, `useWorkspaceTimezone`/`useWorkspaceCurrency`, `useAppContext`, the workspace's zone at the root, `useComments`, a member's links to one record for a person with no seat (`useOutsideLinks`). |
23
+ | [docs/members_and_options.md](./docs/members_and_options.md) | People and select options — `useMembers`, `useFieldOptions`, `useViewer`, `useWorkspaceTimezone`/`useWorkspaceCurrency`, `useAppContext`, the workspace's zone at the root, a member's links to one record for a person with no seat (`useOutsideLinks`). |
24
24
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model, the screens and `drawers` it reports to the host), `useUrlState` + `urlParam` codecs, `useRecents`, `useFolderPick`. |
25
25
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming parts, the agent's ask-back), `askAi`, `useAiContext`, and what the member's own chat agent can do with the app while it is open. |
26
26
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, why a per-input bound is a tenancy floor rather than an authorization check. |
27
- | [docs/runtime.md](./docs/runtime.md) | `mount()` and the errors it reports to the host (`reportAppError`), the two transports, the API address a standalone bundle reads out of its own page (`<meta name="lotics-api-base">`), `rpc()` and `installRpcTransport` (a page that is never an app's answers every op itself), the mock harness (`fixture` + `?__mock=1` — queries, workflows and agents, a mocked agent's run played line by line), `openExternal`/`openApp`/`peekApp`/`downloadFile`, geofencing, the peer dependencies. |
27
+ | [docs/runtime.md](./docs/runtime.md) | `mount()` and the errors it reports to the host (`reportAppError`), the two transports, the API address a standalone bundle reads out of its own page (`<meta name="lotics-api-base">`), `rpc()` and `installRpcTransport` (a page that is never an app's answers every op itself), the mock harness (`fixture` + `?__mock=1` — queries, workflows and agents, a mocked agent's run played line by line), `openExternal`/`downloadFile`, geofencing, the peer dependencies. |
28
28
 
29
29
  ## Non-negotiables (each detailed in its doc)
30
30
 
@@ -154,6 +154,17 @@ function paramsEqual(a, b) {
154
154
  return true;
155
155
  }
156
156
 
157
+ // ../shared/src/openable_url.ts
158
+ var OPENABLE_PROTOCOLS = /* @__PURE__ */ new Set(["https:", "http:", "tel:", "mailto:"]);
159
+ function openableUrl(url) {
160
+ if (!URL.canParse(url)) return void 0;
161
+ const parsed = new URL(url);
162
+ return OPENABLE_PROTOCOLS.has(parsed.protocol) ? parsed : void 0;
163
+ }
164
+ function openTarget(address) {
165
+ return address.protocol === "http:" || address.protocol === "https:" ? "_blank" : "_self";
166
+ }
167
+
157
168
  // ../shared/src/app_host/api_base_meta.ts
158
169
  var API_BASE_META_NAME = "lotics-api-base";
159
170
 
@@ -1794,14 +1805,6 @@ function rpcStandalone(op, payload) {
1794
1805
  return standalonePredictionMiss(payload);
1795
1806
  case "openExternal":
1796
1807
  return standaloneOpenExternal(payload);
1797
- case "peekApp":
1798
- return Promise.reject(
1799
- new Error("peekApp needs the Lotics host: a standalone app has no sibling apps to open. Gate the control on isEmbedded().")
1800
- );
1801
- case "openApp":
1802
- return Promise.reject(
1803
- new Error("openApp needs the Lotics host: a standalone app has no sibling apps to open. Gate the control on isEmbedded().")
1804
- );
1805
1808
  case "askAi":
1806
1809
  return Promise.reject(new Error("askAi is only available when the app runs inside Lotics"));
1807
1810
  case "urlState.get":
@@ -1870,11 +1873,9 @@ async function standaloneMembers(p) {
1870
1873
  }
1871
1874
  function openValidatedUrl(url) {
1872
1875
  if (typeof url !== "string") throw new Error("openExternal requires a url string");
1873
- const parsed = new URL(url);
1874
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
1875
- throw new Error(`openExternal: unsupported URL scheme "${parsed.protocol}"`);
1876
- }
1877
- window.open(parsed.href, "_blank", "noopener,noreferrer");
1876
+ const address = openableUrl(url);
1877
+ if (address === void 0) throw new Error(`openExternal: unsupported URL scheme "${url.split(":", 1)[0]}"`);
1878
+ window.open(address.href, openTarget(address), "noopener,noreferrer");
1878
1879
  }
1879
1880
  async function standaloneOpenExternal(p) {
1880
1881
  openValidatedUrl(p.url);
package/dist/index.d.ts CHANGED
@@ -25,7 +25,6 @@ export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "
25
25
  export { rpc, hostSpec, isEmbedded, installRpcTransport } from "./rpc.js";
26
26
  export type { RpcOp, RpcTransport, HostSpec, AiContextValue, AiContextRecordRef } from "./rpc.js";
27
27
  export { openExternal } from "./open_external.js";
28
- export { openApp, peekApp } from "./open_app.js";
29
28
  export { askAi, type AskAiArgs } from "./ask_ai.js";
30
29
  export { downloadFile } from "./download.js";
31
30
  export { renameFile } from "./rename_file.js";
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  subtractDecimal,
52
52
  sumDecimal,
53
53
  urlParam
54
- } from "./chunk-2KJSE73G.js";
54
+ } from "./chunk-GACWCLEV.js";
55
55
 
56
56
  // src/mount.tsx
57
57
  import { createRoot } from "react-dom/client";
@@ -8298,6 +8298,7 @@ var MEMBER_OF = "member_of";
8298
8298
  var CURRENT_MEMBER_STANDS_IN_FOR = "current_member_stands_in_for";
8299
8299
  var CURRENT_MEMBER_STANDS_IN_FOR_ANY_GROUP = "current_member_stands_in_for_any_group";
8300
8300
  var CHANGED_OUTSIDE = "changed_outside";
8301
+ var OPTION = "option";
8301
8302
  var INLINE_HELPERS = [
8302
8303
  "formatCurrency",
8303
8304
  "randomNumber",
@@ -8315,6 +8316,7 @@ var INLINE_HELPERS = [
8315
8316
  "row_rule_bypassed"
8316
8317
  ];
8317
8318
  var PARSED_ONLY_HELPERS = [CHANGED_OUTSIDE];
8319
+ var SAVE_RESOLVED_HELPERS = [OPTION];
8318
8320
  var HELPERS = /* @__PURE__ */ new Set([
8319
8321
  ...Object.keys(expression_array_exports),
8320
8322
  ...Object.keys(expression_string_exports),
@@ -8324,7 +8326,8 @@ var HELPERS = /* @__PURE__ */ new Set([
8324
8326
  ...Object.keys(jsFunctions),
8325
8327
  ...Object.keys(getDateExpressionFunctions()),
8326
8328
  ...INLINE_HELPERS,
8327
- ...PARSED_ONLY_HELPERS
8329
+ ...PARSED_ONLY_HELPERS,
8330
+ ...SAVE_RESOLVED_HELPERS
8328
8331
  ]);
8329
8332
  function isHelper(name) {
8330
8333
  return HELPERS.has(name);
@@ -10203,10 +10206,8 @@ function useAppContext() {
10203
10206
  insider: data !== void 0 && data.member_id !== null && data.group_ids !== void 0,
10204
10207
  groupIds: data?.group_ids ?? null,
10205
10208
  absences: data?.absences ?? null,
10206
- openableApps: data?.openable_apps ?? null,
10207
10209
  commentsEnabled: data?.comments_enabled ?? false,
10208
10210
  recordingEnabled: data?.recording_enabled === true,
10209
- peekAppEnabled: data?.peek_app_enabled === true,
10210
10211
  hostNamesApp: data?.host_names_app === true,
10211
10212
  timezone: data?.timezone,
10212
10213
  currency: data?.default_currency,
@@ -12781,6 +12782,41 @@ function pickedFirst(a, b) {
12781
12782
  return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
12782
12783
  }
12783
12784
 
12785
+ // ../shared/src/query_column_types.ts
12786
+ var QUERY_COLUMN_TYPES = [
12787
+ "text",
12788
+ "number",
12789
+ "boolean",
12790
+ "date",
12791
+ "datetime",
12792
+ "select",
12793
+ "select_record_link",
12794
+ "select_member",
12795
+ "files",
12796
+ "text_list",
12797
+ "number_list",
12798
+ "boolean_list",
12799
+ "date_list",
12800
+ "datetime_list",
12801
+ "json"
12802
+ ];
12803
+ var LIST_ELEMENT_COLUMN_TYPE = {
12804
+ text_list: "text",
12805
+ number_list: "number",
12806
+ boolean_list: "boolean",
12807
+ date_list: "date",
12808
+ datetime_list: "datetime"
12809
+ };
12810
+ var SCALAR_LIST_COLUMN_TYPES = QUERY_COLUMN_TYPES.filter(
12811
+ (type) => type in LIST_ELEMENT_COLUMN_TYPE
12812
+ );
12813
+ function listColumnTypeOf(element) {
12814
+ return SCALAR_LIST_COLUMN_TYPES.find((type) => LIST_ELEMENT_COLUMN_TYPE[type] === element);
12815
+ }
12816
+ function isScalarListColumnType(type) {
12817
+ return SCALAR_LIST_COLUMN_TYPES.some((one) => one === type);
12818
+ }
12819
+
12784
12820
  // ../shared/src/field_column_type.ts
12785
12821
  function fieldTypeToColumnType(field) {
12786
12822
  switch (field.type) {
@@ -12817,6 +12853,10 @@ function fieldTypeToColumnType(field) {
12817
12853
  }
12818
12854
  }
12819
12855
  }
12856
+ function fieldValueColumnType(field) {
12857
+ const type = fieldTypeToColumnType(field);
12858
+ return isScalarListColumnType(type) ? LIST_ELEMENT_COLUMN_TYPE[type] : type;
12859
+ }
12820
12860
  function formulaOutputType(field) {
12821
12861
  switch (field.formula.output_type) {
12822
12862
  case "number":
@@ -12840,6 +12880,10 @@ function rollupColumnType(field) {
12840
12880
  return field.aggregate_field_format === "datetime" || field.aggregate_field_format === "datetime_range" ? "datetime" : "date";
12841
12881
  }
12842
12882
  function lookupOutputType(field) {
12883
+ const element = lookupElementType(field);
12884
+ return field.lookup_field_several === true ? listColumnTypeOf(element) ?? element : element;
12885
+ }
12886
+ function lookupElementType(field) {
12843
12887
  const t = field.lookup_field_type;
12844
12888
  if (!t) return "json";
12845
12889
  switch (t) {
@@ -12897,7 +12941,8 @@ function fieldValueInfo(field) {
12897
12941
  type: field.lookup_field_type ?? "text",
12898
12942
  ...field.lookup_field_type === "number" ? { notation: fieldNotation(field) } : { format: field.lookup_field_format },
12899
12943
  options: field.lookup_field_options,
12900
- autonumber: field.lookup_field_autonumber
12944
+ autonumber: field.lookup_field_autonumber,
12945
+ several: field.lookup_field_several
12901
12946
  };
12902
12947
  // The cell stores the composed DISPLAY string ("KH-001"), never the counter.
12903
12948
  case "autonumber":
@@ -12975,7 +13020,7 @@ function termsOf(cell, field) {
12975
13020
  const value = field.type === "lookup" && Array.isArray(cell) ? cell[0] : cell;
12976
13021
  if (fieldValueInfo(field).autonumber === true) return autonumberTerms(value);
12977
13022
  const options = effectiveSelectOptions(field);
12978
- return [options === null ? valueRank(value, fieldTypeToColumnType(field)) : optionRank(value, options)];
13023
+ return [options === null ? valueRank(value, fieldValueColumnType(field)) : optionRank(value, options)];
12979
13024
  }
12980
13025
  function rankText(rank) {
12981
13026
  if (rank.by === "name") return folded(rank.value.name);
@@ -14643,14 +14688,6 @@ function openExternal(url) {
14643
14688
  return rpc("openExternal", { url });
14644
14689
  }
14645
14690
 
14646
- // src/open_app.ts
14647
- function openApp(appId, route = "/") {
14648
- return rpc("openApp", { app_id: appId, route });
14649
- }
14650
- function peekApp(appId, route = "/") {
14651
- return rpc("peekApp", { app_id: appId, route });
14652
- }
14653
-
14654
14691
  // src/ask_ai.ts
14655
14692
  function askAi(args) {
14656
14693
  const hasPayload = (args.prompt ?? "") !== "" || (args.file_ids?.length ?? 0) > 0 || (args.record_ids?.length ?? 0) > 0 || (args.context ?? "") !== "";
@@ -14821,9 +14858,7 @@ export {
14821
14858
  isWithinZone,
14822
14859
  mount,
14823
14860
  newRecordId,
14824
- openApp,
14825
14861
  openExternal,
14826
- peekApp,
14827
14862
  queryAll,
14828
14863
  readCreatedAt,
14829
14864
  readFiles,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Open a URL in a new tab — the sandboxed iframe drops a direct `window.open`.
3
- * Only `http`/`https`; anything else rejects.
3
+ * Only the web, `tel:` and `mailto:`; anything else rejects.
4
4
  *
5
5
  * ```tsx
6
6
  * import { openExternal } from "@lotics/app-sdk";
package/dist/router.d.ts CHANGED
@@ -4,6 +4,10 @@ export interface NotFoundWords {
4
4
  message: (path: string) => string;
5
5
  firstScreen: string;
6
6
  }
7
+ export declare function NotFoundScreen({ home, words }: {
8
+ home: string;
9
+ words: NotFoundWords;
10
+ }): import("react").JSX.Element;
7
11
  export declare function AppRouter({ routes, notFound, drawers, }: {
8
12
  routes: RouteObject[];
9
13
  notFound?: NotFoundWords;
package/dist/router.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  isEmbedded,
3
3
  postHostNotification,
4
4
  setUrlParams
5
- } from "./chunk-2KJSE73G.js";
5
+ } from "./chunk-GACWCLEV.js";
6
6
 
7
7
  // src/router.tsx
8
8
  import { useEffect } from "react";
@@ -22,10 +22,18 @@ function screenHref(loc) {
22
22
  const qs = search.toString();
23
23
  return loc.pathname + (qs ? `?${qs}` : "") + loc.hash;
24
24
  }
25
+ function shapeStatedBy(route, pathname) {
26
+ const { handle } = route;
27
+ if (typeof handle !== "object" || handle === null || !("shape" in handle) || typeof handle.shape !== "function") return void 0;
28
+ const shape = Reflect.apply(handle.shape, handle, [pathname]);
29
+ return typeof shape === "string" ? shape : void 0;
30
+ }
25
31
  function routePattern(matches, pathname) {
26
32
  const concrete = pathname.split("/").filter((segment) => segment !== "");
27
33
  let shape = [];
28
34
  for (const { route, params } of matches) {
35
+ const stated = shapeStatedBy(route, pathname);
36
+ if (stated !== void 0) return stated;
29
37
  const path = route.path ?? "";
30
38
  if (path.startsWith("/")) shape = [];
31
39
  for (const segment of path.split("/")) {
@@ -120,5 +128,6 @@ function AppRouter({
120
128
  ] });
121
129
  }
122
130
  export {
123
- AppRouter
131
+ AppRouter,
132
+ NotFoundScreen
124
133
  };
package/dist/rpc.d.ts CHANGED
@@ -1,20 +1,19 @@
1
1
  import type { AppAbsence } from "./shared_types.js";
2
- import type { SpecFile } from "./shared_types.js";
3
- import type { OpenableApp } from "./shared_types.js";
2
+ import type { AppSpec } from "./shared_types.js";
4
3
  import type { AskUserChoiceOutput } from "./agent_stream.js";
5
4
  import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
6
5
  /**
7
6
  * The app's RPC bridge. Bridged (`?lotics_host=` present): ops go over
8
7
  * postMessage and the host, holding the member's session, calls the API.
9
8
  * Standalone: the public `/v1/apps/{id}/*` endpoints are called directly, with
10
- * no credentials. The bridged wire must match `app_iframe_host.tsx`:
9
+ * no credentials. The bridged wire must match `app_frame.tsx`:
11
10
  * app → host: { id, op, payload }, the first also carrying `reportsDrawn: true` and `load` (`AppLoadTiming`) when
12
11
  * the browser states any
13
12
  * app → host: { type: "drawn" }, once, after the first answer when no request has been in flight for two frames:
14
13
  * the app's first screen has its data
15
14
  * host → app: { id, type: "result", data } | { id, type: "error", message }
16
15
  */
17
- export type RpcOp = "query" | "export" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "file.rename" | "members" | "context" | "spec" | "write_model" | "prediction_miss" | "openExternal" | "openApp" | "peekApp" | "askAi" | "urlState.get" | "urlState.set" | "folder.get" | "folder.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts" | "recording.start" | "recording.stop" | "outsideLink.list" | "outsideLink.create" | "outsideLink.revoke";
16
+ export type RpcOp = "query" | "export" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "file.rename" | "members" | "context" | "spec" | "write_model" | "prediction_miss" | "openExternal" | "askAi" | "urlState.get" | "urlState.set" | "folder.get" | "folder.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts" | "recording.start" | "recording.stop" | "outsideLink.list" | "outsideLink.create" | "outsideLink.revoke";
18
17
  export interface AgentRunPayload {
19
18
  alias: string;
20
19
  session_id: string;
@@ -77,8 +76,6 @@ export interface AppContext {
77
76
  /** True only from a host with `recording.*`, for a signed-in member, on an
78
77
  * instance that transcribes; absent reads as false. */
79
78
  recording_enabled?: boolean;
80
- /** True only from a host that serves `peekApp`; absent reads as false. */
81
- peek_app_enabled?: boolean;
82
79
  /** True only from a host whose own chrome names the app on screen; absent reads as false. */
83
80
  host_names_app?: boolean;
84
81
  /** This app's recordings the host still tracks, by alias. */
@@ -86,10 +83,6 @@ export interface AppContext {
86
83
  /** Who in the organization is away, or will be, and who stands in — absent from an older host and for a visitor from
87
84
  * outside the organization. */
88
85
  absences?: AppAbsence[];
89
- /** The apps of the workspace the viewer can open, each by its id and its name, the table its register lists
90
- * (`table_id`) and each of its logs (`logs`) where the host says — absent from an older host, for a visitor and until
91
- * the host has listed them. */
92
- openable_apps?: OpenableApp[];
93
86
  }
94
87
  /** Whether a Lotics host embeds the app, rather than it standing alone on its own origin. */
95
88
  export declare function isEmbedded(): boolean;
@@ -102,7 +95,7 @@ export type RpcTransport = (op: RpcOp, payload: unknown) => Promise<unknown>;
102
95
  * last install wins, and a page installs its own before it mounts. */
103
96
  export declare function installRpcTransport(transport: RpcTransport): void;
104
97
  /** What the host answers `spec`: the app's compiled spec itself, never the route's wrapper around it. */
105
- export type HostSpec = SpecFile;
98
+ export type HostSpec = AppSpec;
106
99
  /** The app's spec, as the host hands it over the bridge. */
107
100
  export declare function hostSpec(): Promise<HostSpec>;
108
101
  export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;