@lotics/app-sdk 0.103.0 → 0.105.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 +2 -3
- package/dist/agent_stream.d.ts +3 -3
- package/dist/hooks.d.ts +7 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/members.d.ts +2 -3
- package/dist/queries.d.ts +15 -10
- package/dist/recording.d.ts +3 -3
- package/dist/row.d.ts +1 -2
- package/dist/select.d.ts +2 -2
- package/dist/viewer.d.ts +1 -1
- package/docs/ai.md +14 -27
- package/docs/data_fetching.md +24 -36
- package/docs/files.md +26 -54
- package/docs/members_and_options.md +28 -41
- package/docs/mutations.md +5 -7
- package/docs/navigation_and_state.md +1 -2
- package/docs/runtime.md +8 -8
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
hooks over the host bridge, the cell readers and `mount()`; `@lotics/app-sdk/router` is
|
|
5
5
|
`AppRouter`. Data and RPC only — no component. What the app draws is its own.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Where a doc pairs an SDK value with a kit component, that pairing applies in a sandbox session.
|
|
7
|
+
**Draw with any UI library.** The SDK hands over data and nothing to draw it with, so the app
|
|
8
|
+
picks its own components; the docs' examples use plain elements.
|
|
10
9
|
|
|
11
10
|
This file is the index. The **exact type** of anything is its shipped declaration,
|
|
12
11
|
`dist/<name>.d.ts` for a hook or reader — **never guess a shape; open the file.**
|
package/dist/agent_stream.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Folds the AI-SDK UI-message SSE stream into the `UIMessagePart[]`
|
|
3
|
-
*
|
|
2
|
+
* Folds the AI-SDK UI-message SSE stream into the ai-sdk `UIMessagePart[]`
|
|
3
|
+
* shape. `ai` is imported for types only, so no `ai`
|
|
4
4
|
* runtime enters the app bundle; unknown chunk types are ignored. A structured
|
|
5
5
|
* agent's `output` is the result the server accepted (`data-run-output`, sent
|
|
6
6
|
* before `finish`); until it arrives, `submit_result`'s input — never a part.
|
|
@@ -74,7 +74,7 @@ export interface PendingChoice {
|
|
|
74
74
|
}
|
|
75
75
|
/** The pending `ask_user_choice`, non-null exactly while parked. */
|
|
76
76
|
export declare function pendingInteractiveCall(state: AgentRunState): PendingChoice | null;
|
|
77
|
-
/** `answers` align to `questions` by index
|
|
77
|
+
/** `answers` align to `questions` by index; a missing or empty one is skipped. */
|
|
78
78
|
export declare function buildChoiceOutput(questions: ChoiceQuestion[], answers: {
|
|
79
79
|
value: string;
|
|
80
80
|
custom: boolean;
|
package/dist/hooks.d.ts
CHANGED
|
@@ -91,14 +91,8 @@ export declare function useFileUpload(): FileUploadState;
|
|
|
91
91
|
* ```tsx
|
|
92
92
|
* const { files, add, remove, clear, uploading, fileIds } = useAttachments();
|
|
93
93
|
* const design = useWorkflow("design");
|
|
94
|
-
* // attach: <
|
|
95
|
-
* // preview: map
|
|
96
|
-
* // app owns this data→UI adapter; the SDK never imports @lotics/ui:
|
|
97
|
-
* // files.map((f) => (
|
|
98
|
-
* // <FileThumbnail
|
|
99
|
-
* // file={{ id: f.id, filename: f.filename, mimeType: f.mime_type, url: f.preview_url }}
|
|
100
|
-
* // uploading={f.status === "uploading"} onRemove={() => remove(f.id)} />
|
|
101
|
-
* // ))
|
|
94
|
+
* // attach: <button onClick={() => pickFiles({ accept: "image/*" }).then(add)}>Attach</button>
|
|
95
|
+
* // preview: files.map((f) => <img key={f.id} src={f.preview_url} alt={f.filename} />)
|
|
102
96
|
* // send: design({ photo: fileIds[0] }); clear();
|
|
103
97
|
* ```
|
|
104
98
|
*/
|
|
@@ -161,9 +155,7 @@ export interface MembersOptions {
|
|
|
161
155
|
*
|
|
162
156
|
* ```tsx
|
|
163
157
|
* const { members } = useMembers({ group: GRP.sale });
|
|
164
|
-
* //
|
|
165
|
-
* // value: m.id, label: m.name || m.email || m.id, image: m.image,
|
|
166
|
-
* // }))} />
|
|
158
|
+
* // members.map((m) => <option key={m.id} value={m.id}>{m.name || m.email || m.id}</option>)
|
|
167
159
|
* ```
|
|
168
160
|
*/
|
|
169
161
|
export declare function useMembers(opts?: MembersOptions): MembersState;
|
|
@@ -186,9 +178,9 @@ export interface UseAgentRun<TInput, TOutput> {
|
|
|
186
178
|
cancel: () => void;
|
|
187
179
|
/** `awaiting_input`: parked on the agent's question (`pendingChoice`). */
|
|
188
180
|
status: "idle" | "streaming" | "awaiting_input" | "completed" | "error";
|
|
189
|
-
/** The ordered transcript,
|
|
181
|
+
/** The ordered transcript, as ai-sdk `UIMessage.parts`. */
|
|
190
182
|
parts: AgentUIPart[];
|
|
191
|
-
/** Non-null exactly while `awaiting_input
|
|
183
|
+
/** Non-null exactly while `awaiting_input`. */
|
|
192
184
|
pendingChoice: PendingChoice | null;
|
|
193
185
|
/** Answer the pending ask (one `{value, custom}` per question, by index) and
|
|
194
186
|
* continue the run; resolves like `run`. Rejects when nothing is pending or
|
|
@@ -211,8 +203,8 @@ export interface UseAgentRun<TInput, TOutput> {
|
|
|
211
203
|
* ```tsx
|
|
212
204
|
* const recognize = useAgentRun("recognize");
|
|
213
205
|
* await recognize.run({ image_file_id }, { sessionId });
|
|
214
|
-
* //
|
|
215
|
-
* //
|
|
206
|
+
* // recognize.parts streams the transcript; then read recognize.output
|
|
207
|
+
* // (structured) or recognize.text (free-text)
|
|
216
208
|
* ```
|
|
217
209
|
*/
|
|
218
210
|
export declare function useAgentRun<K extends keyof AppAgents & string>(alias: K): UseAgentRun<AppAgents[K], AgentOutputOf<K>>;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** `@lotics/app-sdk`: hooks, cell readers and `mount()`. Data and RPC only — no
|
|
1
|
+
/** `@lotics/app-sdk`: hooks, cell readers and `mount()`. Data and RPC only — no component. */
|
|
2
2
|
export { mount } from "./mount.js";
|
|
3
3
|
export type { MountOptions } from "./mount.js";
|
|
4
4
|
export { reportAppError } from "./error_report.js";
|
package/dist/index.js
CHANGED
|
@@ -24214,7 +24214,7 @@ var appWorkflowContractSchema = zod_default.object({
|
|
|
24214
24214
|
inputs: zod_default.record(zod_default.string(), appWorkflowInputSchema).optional(),
|
|
24215
24215
|
outputs: zod_default.record(zod_default.string(), appWorkflowOutputSchema).optional()
|
|
24216
24216
|
});
|
|
24217
|
-
var APP_EXPORT_REPORT_KEYS = ["title", "lines", "readings"];
|
|
24217
|
+
var APP_EXPORT_REPORT_KEYS = ["title", "lines", "readings", "at", "dates"];
|
|
24218
24218
|
var appExportTemplateSchema = zod_default.object({
|
|
24219
24219
|
template_id: zod_default.string().min(1).describe("The document template (`dtl_\u2026`) filled: an Excel workbook, or an HTML page made a PDF."),
|
|
24220
24220
|
rows: zod_default.string().regex(APP_ALIAS_REGEX).refine((key) => !APP_EXPORT_REPORT_KEYS.some((taken) => taken === key), {
|
package/dist/members.d.ts
CHANGED
|
@@ -22,9 +22,8 @@ export interface ResolvedMember {
|
|
|
22
22
|
/** ISO timestamp of joining the organization. */
|
|
23
23
|
joined?: string;
|
|
24
24
|
/**
|
|
25
|
-
* `true` for a member who has left; never `false`.
|
|
26
|
-
*
|
|
27
|
-
* departed member cannot be assigned.
|
|
25
|
+
* `true` for a member who has left; never `false`. The roster never returns
|
|
26
|
+
* one, since a departed member cannot be assigned.
|
|
28
27
|
*/
|
|
29
28
|
archived?: true;
|
|
30
29
|
}
|
package/dist/queries.d.ts
CHANGED
|
@@ -56,8 +56,7 @@ export interface QueryFilterGroup<C extends string = string> {
|
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
58
|
* Runtime filter applied after the named query, bounded to its projected
|
|
59
|
-
* columns — by `C` at compile time and again on the server.
|
|
60
|
-
* `columnFilterToConditions` (`@lotics/ui/column_filter`).
|
|
59
|
+
* columns — by `C` at compile time and again on the server.
|
|
61
60
|
*/
|
|
62
61
|
export type QueryFilter<C extends string = string> = QueryFilterCondition<C> | QueryFilterGroup<C>;
|
|
63
62
|
/** A `filter`/`sort` key on alias `K`: its projected columns, else `string`. */
|
|
@@ -185,6 +184,13 @@ export interface ExportReport {
|
|
|
185
184
|
title: string;
|
|
186
185
|
lines: string[];
|
|
187
186
|
readings: ReportReading[];
|
|
187
|
+
/** Each date filter narrowing the rows, by field alias: its bounds, a day or a minute of one, with no zone. */
|
|
188
|
+
dates?: Record<string, {
|
|
189
|
+
from?: string;
|
|
190
|
+
to?: string;
|
|
191
|
+
}>;
|
|
192
|
+
/** The file's name, in the template grammar over the report; absent, the template's name, or `title`. */
|
|
193
|
+
filename?: string;
|
|
188
194
|
}
|
|
189
195
|
/** The file an export makes: the default report workbook — its words in `language`, the rows under `columns` on the
|
|
190
196
|
* tab `sheet` — or a template the query declares under `templates`, by its name there. */
|
|
@@ -232,8 +238,8 @@ export interface FieldOptionsState<C extends string = string> {
|
|
|
232
238
|
fields: Partial<Record<C, FieldOptions>>;
|
|
233
239
|
/**
|
|
234
240
|
* A number column read in each row's own unit or currency, keyed the same way: a
|
|
235
|
-
* condition comparing it names one of these as its `unit_option
|
|
236
|
-
*
|
|
241
|
+
* condition comparing it names one of these as its `unit_option`. A figure read in one
|
|
242
|
+
* unit for every row has none.
|
|
237
243
|
*/
|
|
238
244
|
units: Partial<Record<C, FigureUnits>>;
|
|
239
245
|
loading: boolean;
|
|
@@ -252,13 +258,12 @@ export interface FieldOptionsOptions {
|
|
|
252
258
|
*
|
|
253
259
|
* ```tsx
|
|
254
260
|
* const { fields, units } = useFieldOptions("records");
|
|
255
|
-
* // populate
|
|
256
|
-
*
|
|
257
|
-
* renderOptionContent={(o) => <Status option={o} />} />
|
|
261
|
+
* // populate a picker:
|
|
262
|
+
* (fields.status?.options ?? []).map((o) => <option key={o.key} value={o.key}>{o.label}</option>)
|
|
258
263
|
* // color a stored value:
|
|
259
|
-
*
|
|
260
|
-
* // a range on a figure each row reads in its own unit:
|
|
261
|
-
*
|
|
264
|
+
* fields.status?.byKey(readSelect(r.status)[0]?.key ?? "")?.color
|
|
265
|
+
* // a range on a figure each row reads in its own unit names one:
|
|
266
|
+
* units.weight?.options.map((o) => o.key) // → a condition's `unit_option`
|
|
262
267
|
* ```
|
|
263
268
|
*/
|
|
264
269
|
export declare function useFieldOptions<K extends keyof AppQueries & string>(alias: K, opts?: FieldOptionsOptions): FieldOptionsState<ColumnKeyOf<K>>;
|
package/dist/recording.d.ts
CHANGED
|
@@ -33,12 +33,12 @@ export interface UseRecording<I> {
|
|
|
33
33
|
* const visit = useRecording("log_visit");
|
|
34
34
|
* if (!visit.available) return null;
|
|
35
35
|
* if (visit.state.phase === "live" && visit.state.inputs.site === siteId) {
|
|
36
|
-
* return <
|
|
36
|
+
* return <button onClick={() => visit.stop()}>Stop</button>;
|
|
37
37
|
* }
|
|
38
38
|
* return (
|
|
39
|
-
* <
|
|
39
|
+
* <button disabled={visit.busy} onClick={() => visit.start({ site: siteId })}>
|
|
40
40
|
* Record the visit
|
|
41
|
-
* </
|
|
41
|
+
* </button>
|
|
42
42
|
* );
|
|
43
43
|
* ```
|
|
44
44
|
*/
|
package/dist/row.d.ts
CHANGED
|
@@ -54,8 +54,7 @@ export interface AppFile {
|
|
|
54
54
|
document_template_id?: string;
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
|
-
* The presigned files, skipping any without a `url
|
|
58
|
-
* (`@lotics/ui/file_thumbnail`) converts one for the kit. A lookup arrives one
|
|
57
|
+
* The presigned files, skipping any without a `url`. A lookup arrives one
|
|
59
58
|
* level deeper, flattened as in {@link readLinks}.
|
|
60
59
|
*/
|
|
61
60
|
export declare function readFiles(v: unknown): AppFile[];
|
package/dist/select.d.ts
CHANGED
|
@@ -5,10 +5,10 @@ export interface ResolvedOption {
|
|
|
5
5
|
label: string;
|
|
6
6
|
/**
|
|
7
7
|
* A palette token (`"blue"`), from `useFieldOptions` only — a cell carries
|
|
8
|
-
* key and label.
|
|
8
|
+
* key and label.
|
|
9
9
|
*/
|
|
10
10
|
color?: string;
|
|
11
|
-
/** A brand or icon mark, from `useFieldOptions` only
|
|
11
|
+
/** A brand or icon mark, from `useFieldOptions` only. */
|
|
12
12
|
mark?: {
|
|
13
13
|
kind: "brand";
|
|
14
14
|
name: string;
|
package/dist/viewer.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export declare function useAppContext(): {
|
|
|
27
27
|
export declare function useWorkspaceTimezone(): string | undefined;
|
|
28
28
|
/**
|
|
29
29
|
* The ISO 4217 code for money whose field states none — pass it as `currency`,
|
|
30
|
-
* or
|
|
30
|
+
* or a money format prints a default market. `undefined` until resolved.
|
|
31
31
|
*/
|
|
32
32
|
export declare function useWorkspaceCurrency(): string | undefined;
|
|
33
33
|
/**
|
package/docs/ai.md
CHANGED
|
@@ -13,7 +13,7 @@ Don't run a structured extraction through `askAi` (the result is stranded in a c
|
|
|
13
13
|
|
|
14
14
|
## Declared agents — what `useAgentRun` runs
|
|
15
15
|
|
|
16
|
-
An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_agent`) — the one way an agent is bound or changed, each write a new version of the app; a deploy never touches it, and `get_app_agent` reads one back. Invoking an alias that isn't bound fails with a "no agent alias" error naming `set_app_agent
|
|
16
|
+
An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_agent`) — the one way an agent is bound or changed, each write a new version of the app; a deploy never touches it, and `get_app_agent` reads one back. Invoking an alias that isn't bound fails with a "no agent alias" error naming `set_app_agent`.
|
|
17
17
|
|
|
18
18
|
A declaration carries:
|
|
19
19
|
|
|
@@ -39,7 +39,7 @@ A declaration carries:
|
|
|
39
39
|
|
|
40
40
|
So an agent that reads or writes records needs a named query or workflow for each thing it touches — the same declaration the app's own UI uses. Declare only what that agent needs; a second agent in the same app can declare a narrower set.
|
|
41
41
|
|
|
42
|
-
**Typing.** `.lotics/app_agents.d.ts`, generated from the app's live bindings
|
|
42
|
+
**Typing.** `.lotics/app_agents.d.ts`, generated from the app's live bindings by `lotics app create --custom` and `lotics app deploy`, augments the SDK's `AppAgents` (alias → input shape) and `AppAgentResults` (alias → declared output shape) interfaces — `useAgentRun("recognize")` then types both `run(input)` and `output`. An alias it does not carry falls back to `Record<string, unknown>` input / `unknown` output.
|
|
43
43
|
|
|
44
44
|
---
|
|
45
45
|
|
|
@@ -62,35 +62,26 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
|
|
|
62
62
|
| `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this; the platform stamps `app_agent_runs.cancel_requested_at` |
|
|
63
63
|
| `abort` | `() => void` | Stop listening **locally only** — the run keeps executing server-side and its result is still persisted. This is the unmount path (the hook calls it automatically on unmount) |
|
|
64
64
|
| `status` | `"idle" \| "streaming" \| "awaiting_input" \| "completed" \| "error"` | Whole-run state. `awaiting_input` = the run is PARKED on a question the agent asked (see the ask-back section below). `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
|
|
65
|
-
| `pendingChoice` | `PendingChoice \| null` | The agent's pending question(s) — non-null exactly while `status` is `awaiting_input`.
|
|
66
|
-
| `answerChoice` | `(answers: {value, custom}[]) => Promise<AgentRunLanding<TOutput>>` | Answer the pending question(s) and CONTINUE the run — one entry per question, aligned by index
|
|
67
|
-
| `parts` | `AgentUIPart[]` | The ordered live transcript as **ai-sdk `UIMessage.parts`** — answer prose, thinking, and tool calls, in stream order. The single source of truth for the feed
|
|
65
|
+
| `pendingChoice` | `PendingChoice \| null` | The agent's pending question(s) — non-null exactly while `status` is `awaiting_input`. Each of its `questions` is `{question, options: {label, description}[], allow_custom}` |
|
|
66
|
+
| `answerChoice` | `(answers: {value, custom}[]) => Promise<AgentRunLanding<TOutput>>` | Answer the pending question(s) and CONTINUE the run — one entry per question, aligned by index. Streams the continuation into the same `parts`; resolves like `run`. Rejects when nothing is pending — and when the server refuses the answer, in which case the pending question is restored for a retry |
|
|
67
|
+
| `parts` | `AgentUIPart[]` | The ordered live transcript as **ai-sdk `UIMessage.parts`** — answer prose, thinking, and tool calls, in stream order. The single source of truth for the feed |
|
|
68
68
|
| `text` | `string` | The agent's **answer prose** (every `text` part concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
|
|
69
69
|
| `output` | `TOutput \| undefined` | The structured result the server accepted — read it once `status` is `"completed"`. `undefined` for a free-text agent |
|
|
70
70
|
| `error` | `string \| undefined` | The failure message when `status` is `"error"` |
|
|
71
71
|
|
|
72
|
-
### The `parts` transcript
|
|
72
|
+
### The `parts` transcript
|
|
73
73
|
|
|
74
74
|
`parts` is the **ai-sdk `UIMessagePart[]`** shape — the same wire model chat and app agents both emit, so there is no bespoke transcript type to keep in sync. The reducer folds the run's SSE chunks into it, in stream order:
|
|
75
75
|
|
|
76
76
|
| Part `type` | Carries | Rendering intent |
|
|
77
77
|
|---|---|---|
|
|
78
78
|
| `"text"` | `text`, `state` | Answer prose, grows as it streams |
|
|
79
|
-
| `"reasoning"` | `text`, `state` | The agent's thinking — a **distinct part**, kept out of `text`, so
|
|
80
|
-
| `"dynamic-tool"` | `toolName`, `toolCallId`, `state` (ai's tool lifecycle — `input-available` → `output-available` / `output-error`), `input`, `output`, `errorText` | One tool call — opens running the moment it fires, settles when its result arrives. `input`/`output` ride along for
|
|
79
|
+
| `"reasoning"` | `text`, `state` | The agent's thinking — a **distinct part**, kept out of `text`, so a feed can show it collapsed and reveal it on demand. Only produced when the agent's declared model supports thinking (**Sonnet / Opus**, not Haiku) — a Haiku agent never emits reasoning |
|
|
80
|
+
| `"dynamic-tool"` | `toolName`, `toolCallId`, `state` (ai's tool lifecycle — `input-available` → `output-available` / `output-error`), `input`, `output`, `errorText` | One tool call — opens running the moment it fires, settles when its result arrives. `input`/`output` ride along for an on-demand reveal, not for the feed row |
|
|
81
81
|
|
|
82
|
-
The terminal `submit_result` call is **not** rendered as a tool part (structured agents therefore have no visible final step); `output` is the result the server accepted, sent as the run finishes. Source/file parts aren't emitted by app agents. `parts` is
|
|
82
|
+
The terminal `submit_result` call is **not** rendered as a tool part (structured agents therefore have no visible final step); `output` is the result the server accepted, sent as the run finishes. Source/file parts aren't emitted by app agents. Because `parts` is the ai-sdk shape, any renderer of ai-sdk message parts draws it with no adapter.
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
<AgentRun
|
|
86
|
-
parts={run.parts}
|
|
87
|
-
state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"}
|
|
88
|
-
error={run.error} // the breaking error — a terminal danger row
|
|
89
|
-
labelForCall={(call) => toolLabels[call.toolName]} // optional: localize tool labels
|
|
90
|
-
/>
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
`AgentRun` renders thinking collapsed, groups consecutive tool calls, and expands each tool's input/output in place on press — all for free. Running it in a bounded container (a dialog, a panel)? Wrap it in `@lotics/ui`'s `FollowScroll` so the container follows the stream instead of letting new content grow below the fold. **Errors surface two ways:** a per-tool failure is an `output-error` part (amber dot, reason in the expanded Error panel — the feed flows on, exactly like a run that retried and recovered); a **breaking** error that killed the run lives in `run.error` (not in `parts`) — pass it as `error` and it renders as a terminal danger row. Never rebuild this feed by hand. To see its live, done and failed states without a run, mock the agent (`fixture.agents`, [runtime](./runtime.md)): the run is played through the same stream reader, never billed.
|
|
84
|
+
**Errors surface two ways:** a per-tool failure is an `output-error` part — the feed flows on, exactly like a run that retried and recovered; a **breaking** error that killed the run lives in `run.error` (not in `parts`), and is the run's last word. A feed in a bounded container (a dialog, a panel) should follow the stream, so new content never grows below the fold. To see its live, done and failed states without a run, mock the agent (`fixture.agents`, [runtime](./runtime.md)): the run is played through the same stream reader, never billed.
|
|
94
85
|
|
|
95
86
|
`run.error` is always a **sentence to show a person**, never a payload — on both the ways a run can fail.
|
|
96
87
|
|
|
@@ -114,20 +105,16 @@ same eventual `output`:
|
|
|
114
105
|
const run = useAgentRun("importer");
|
|
115
106
|
// …
|
|
116
107
|
{run.pendingChoice ? (
|
|
117
|
-
<
|
|
118
|
-
questions={run.pendingChoice.questions
|
|
119
|
-
|
|
120
|
-
answers: q.options.map((o) => ({ value: o.label, label: o.label, description: o.description })),
|
|
121
|
-
allowCustom: q.allow_custom,
|
|
122
|
-
}))}
|
|
123
|
-
onSubmit={(answers) => { void run.answerChoice(answers); }}
|
|
108
|
+
<ChoiceForm
|
|
109
|
+
questions={run.pendingChoice.questions} // your own form: one pick (or free text) per question
|
|
110
|
+
onSubmit={(answers) => { void run.answerChoice(answers); }} // [{ value, custom }], one per question
|
|
124
111
|
onCancel={() => run.cancel()}
|
|
125
112
|
/>
|
|
126
113
|
) : null}
|
|
127
114
|
```
|
|
128
115
|
|
|
129
116
|
The ask renders in the feed as a settled tool row once answered. `run()` resolves
|
|
130
|
-
`{kind:"parked"}` when the run parks — the
|
|
117
|
+
`{kind:"parked"}` when the run parks — the form renders off `pendingChoice` — and the
|
|
131
118
|
continuation's landing (from `answerChoice`) carries the final output, or `parked` again for a
|
|
132
119
|
follow-up ask. A dropped stream can't lose the question: the recovery poll rebuilds it from the
|
|
133
120
|
persisted run, so `awaiting_input` always yields an answerable `pendingChoice` (or a retryable
|
package/docs/data_fetching.md
CHANGED
|
@@ -11,20 +11,20 @@ alias** (bound with `set_app_query`) and fills the template's declared `{{params
|
|
|
11
11
|
value holes; the server holds the canonical AST and runs it under the **app owner's** authority
|
|
12
12
|
([./security.md](./security.md)). The generated `.lotics/app_queries.d.ts` augments `AppQueries`,
|
|
13
13
|
so params are typed per the binding; an alias it does not carry is accepted as a plain string,
|
|
14
|
-
untyped. It is written from the app's live bindings
|
|
15
|
-
|
|
14
|
+
untyped. It is written from the app's live bindings by `lotics app create --custom` and
|
|
15
|
+
`lotics app deploy`. Every hook reads through the SDK's own cache, over the host RPC bridge.
|
|
16
16
|
|
|
17
17
|
## Choosing a read
|
|
18
18
|
|
|
19
19
|
| Call | Answers | Reach for it when |
|
|
20
20
|
|---|---|---|
|
|
21
21
|
| `useQuery(alias, params?, opts?)` | `rows` up to the server's cap, or the first `limit` | a detail read, a dashboard block, a combobox's top-N — anything that is not a long list |
|
|
22
|
-
| `useQuery(alias, params?, { page: n })` | one numbered page of `n` rows, `total`, `pageCount`, `setPage` | numbered, jumpable pages
|
|
22
|
+
| `useQuery(alias, params?, { page: n })` | one numbered page of `n` rows, `total`, `pageCount`, `setPage` | numbered, jumpable pages |
|
|
23
23
|
| `useQuery(alias, params?, { more: n })` | accumulated `rows` + `loadMore` | infinite scroll / "load more" feeds |
|
|
24
24
|
| `useQuery(alias, params?, { rows: false, total: true })` | `total` only, no rows (`total: { by: column }` adds `counts`) | a facet chip, a queue badge, an "N awaiting approval" tile — the size of a set you are not listing |
|
|
25
25
|
| `useQueries(calls, opts?)` | one state per `{ alias, params?, filter?, sort?, aggregate?, total?, rows? }`, in order — a `sort` orders that call's rows before the cap cuts them | reads that are DATA — a list known only at render, one read per item, or one count per stage (`rows: false, total: true`, each state's `.total`); the deploy's alias scan reads a list as dynamic, so every alias in it is still declared. With `aggregate` a call answers the groups of its filtered rows instead of the rows ([queries](./queries.md)) |
|
|
26
26
|
| `queryAll(alias, params?, { filter, sort })` | a promise of every row | outside React, for a job that must hold the whole narrowed set in the browser (the ids an act runs on): it asks page after page until the server says the set ended, never stopping at the 10,000-row cap. The server's rows as stored — no write of this app is drawn over them |
|
|
27
|
-
| `exportQuery(alias, { params, filter, sort, report, file })` | a promise of the file | every row the narrowed set holds, made into one file by the server — no row reaches the browser. `report` is `{ title, lines, readings }
|
|
27
|
+
| `exportQuery(alias, { params, filter, sort, report, file })` | a promise of the file | every row the narrowed set holds, made into one file by the server — no row reaches the browser. `report` is `{ title, lines, readings, dates?, filename? }` — `dates` each date filter's `{ from?, to? }` by field alias, each bound a day (`yyyy-MM-dd`) or a minute of one (`yyyy-MM-ddTHH:mm`) with no zone; `filename` the file's name in the template grammar over one value each — `title`, `at` (the moment the server made the file), `dates.<field>.from` or `.to`, `lines | lookup:<n>` — at most 200 bytes once filled (absent, the template's name, or `title`); `file` is `{ kind: "workbook", language, sheet, columns }` (the default report workbook, its head closing with the moment the server made it, each column `{ key, header, type }` an output column of the query — a `number` one on a number column, a `date` one on a date or datetime) or `{ kind: "template", template }`, one the query declares under `templates` by name ([queries](./queries.md)). Past 20,000 rows it rejects, never cuts; each caller makes at most 60 exports a minute. The file's `url` is signed for the caller: open it with `openExternal` |
|
|
28
28
|
|
|
29
29
|
Every hook answers the same `QueryState`: `rows`, `truncated`, `total`, `counts`, `loading`,
|
|
30
30
|
`isValidating`, `error`, `pending`, `refetch`, `page`, `setPage`, `pageCount`, `hasMore`,
|
|
@@ -86,7 +86,7 @@ all its calls, and each call states its own `filter`, `sort`, `total` and `rows`
|
|
|
86
86
|
| `enabled` | `boolean` | `true` | `false` = no request is sent, `rows` is `[]`, `loading` is `false`. Flip to `true` to fetch. The gate for search-as-you-type and on-demand detail. Disabling also **hides** previously loaded rows (the cached answer survives; it re-renders instantly when re-enabled). |
|
|
87
87
|
| `revalidateOnFocus` | `boolean` | `true` | `false` = no re-read on window focus / tab return / network reconnect (`refetch()` still works). Keep the default for dashboards; turn off for transient queries (a search bound to an ephemeral term) where a refocus re-run is wasted work and a visible reload. |
|
|
88
88
|
| `sort` | `QuerySortKey[]` | — | Runtime sort, applied server-side **after** the named query, over its output columns: `[{ field_key, order: "asc" \| "desc", blank_position? }]`. An empty/omitted array leaves the query's own order intact. Part of the read's key — changing it re-queries. |
|
|
89
|
-
| `filter` | `QueryFilter` | — | Runtime filter, applied server-side after the named query, over its output columns. A single condition or a recursive `{ node_type: "group", logic: "and" \| "or", children }`. Part of the read's key.
|
|
89
|
+
| `filter` | `QueryFilter` | — | Runtime filter, applied server-side after the named query, over its output columns. A single condition or a recursive `{ node_type: "group", logic: "and" \| "or", children }`. Part of the read's key. |
|
|
90
90
|
| `limit` | `number` | — | The first `limit` rows only — a **cap**, not pages; omit to read up to the server's row cap. |
|
|
91
91
|
| `page` | `number` | — | Numbered pages of `page` rows: `page` (from 0), `setPage`, `pageCount`, `hasMore`, and `total`, counted by default. |
|
|
92
92
|
| `pageAt` | `{ at, onChange }` | — | With `page`, the page the screen keeps (its address, so a way back or a reload opens on it): `at` is read, `setPage` calls `onChange`, and nothing resets it — send a changed read back to 0 yourself. |
|
|
@@ -119,10 +119,6 @@ check reaches the helper that derives the key, not only the hook call that sends
|
|
|
119
119
|
projected columns — ask the alias that CARRIES the column
|
|
120
120
|
([./members_and_options.md](./members_and_options.md)).
|
|
121
121
|
|
|
122
|
-
`@lotics/ui`'s `columnFilterToConditions` carries the same parameter (`FilterableColumn<C>` →
|
|
123
|
-
`FilterConditionNode<C>`), so a per-column filter UI built over a typed alias composes without a
|
|
124
|
-
cast.
|
|
125
|
-
|
|
126
122
|
Refinement order is fixed: the runtime **filter narrows** the named query's result, then **sort
|
|
127
123
|
orders** it, then **`limit` / `page` / `more` cut** it. The template's own filters/sort/limit run first,
|
|
128
124
|
inside the named query.
|
|
@@ -304,7 +300,7 @@ const { rows, total, pageCount, page, setPage, hasMore } =
|
|
|
304
300
|
useQuery("orders", { q }, { page: 25, sort, filter });
|
|
305
301
|
```
|
|
306
302
|
|
|
307
|
-
The page model behind a numbered table
|
|
303
|
+
The page model behind a numbered table. It owns the page
|
|
308
304
|
index and reads two things: the current page, and a count over the filtered set.
|
|
309
305
|
|
|
310
306
|
- **Result-set identity is `(params, filter, sort)`.** Changing any of them resets to page 0.
|
|
@@ -416,14 +412,14 @@ and the UI prints `00:00`. When you need the time, the query must output the col
|
|
|
416
412
|
`"2026-05"` (month) or `"2026"` (year) — when a document carries only that precision. Both decoders
|
|
417
413
|
resolve it to its **period start** (missing month/day → 1): `row.date("2026-05")` →
|
|
418
414
|
May 1 2026 at local midnight, `row.date("2026")` → Jan 1 2026 — never `null`. For DISPLAY that
|
|
419
|
-
respects the precision (show "05/2026", not "01/05/2026"), format
|
|
420
|
-
|
|
415
|
+
respects the precision (show "05/2026", not "01/05/2026"), format the raw cell string rather than
|
|
416
|
+
the decoded `Date`. Sorting/filtering server-side already treats
|
|
421
417
|
a partial as its period start.
|
|
422
418
|
|
|
423
419
|
**`readSelect`.** A query **cell** carries `key` + `label` only — `color` comes from
|
|
424
420
|
`useFieldOptions`, not the cell. An option deleted after the cell was written surfaces as
|
|
425
|
-
`label === key` (the stale state is explicit, never hidden)
|
|
426
|
-
|
|
421
|
+
`label === key` (the stale state is explicit, never hidden) — see
|
|
422
|
+
[./members_and_options.md](./members_and_options.md).
|
|
427
423
|
|
|
428
424
|
**`readMembers`.** The member shape, its gates and its `null` name:
|
|
429
425
|
[./members_and_options.md](./members_and_options.md#member-cells-readmembers).
|
|
@@ -446,8 +442,8 @@ show the locked state and route edits through the locked-change request flow
|
|
|
446
442
|
|
|
447
443
|
**`readCreatedAt` / `readUpdatedAt`.** Take the whole row object, not a cell. Unlike a `date`/`datetime` CELL (a timezone-less
|
|
448
444
|
workspace wall-clock at minute precision), these are true **instants**: ISO-8601 with an offset on
|
|
449
|
-
the wire, parsed as instants, dated in the workspace's zone — `useWorkspaceTimezone()
|
|
450
|
-
|
|
445
|
+
the wire, parsed as instants, dated in the workspace's zone — `useWorkspaceTimezone()`
|
|
446
|
+
([members & options](./members_and_options.md)). `null` on a grouped row, which has no
|
|
451
447
|
originating record.
|
|
452
448
|
|
|
453
449
|
## Complete select option sets
|
|
@@ -471,18 +467,12 @@ never options derived from loaded rows, which are incomplete until every page lo
|
|
|
471
467
|
- **Diff before update.** An edit form snapshots the record at load and sends only the CHANGED
|
|
472
468
|
fields to its update workflow. The full rule and the locked-record path:
|
|
473
469
|
[./mutations.md](./mutations.md).
|
|
474
|
-
- **Reuse the kit's utilities.** `@lotics/ui` ships the formatters (`formatMoney`, `formatDate` /
|
|
475
|
-
`parseDate` / `toISODate`) — never hand-roll `dd/MM` or currency strings. Grep `@lotics/ui`
|
|
476
|
-
exports before writing one.
|
|
477
470
|
|
|
478
471
|
## Search-as-you-type
|
|
479
472
|
|
|
480
|
-
A search-first picker (type → ranked results → pick) is
|
|
473
|
+
A search-first picker (type → ranked results → pick) is the app's own combobox over three SDK
|
|
481
474
|
pieces. Compose these — don't hand-roll search:
|
|
482
475
|
|
|
483
|
-
- **`Combobox`** (`@lotics/ui/combobox`) owns the interaction — debounced `onSearchChange`, a
|
|
484
|
-
popover listbox with rich rows (`renderOptionContent`), keyboard navigation, `recentOptions`,
|
|
485
|
-
`allowCustom`. (For a known small list with no search box, `Select`.)
|
|
486
476
|
- **A parameterized `search` query** — a `from_table` with `search: "{{params.q}}"` over the
|
|
487
477
|
maintained search document: **diacritics- and case-insensitive**, trigram-indexed, and AND-ed
|
|
488
478
|
with the template's `filter` (search within a scope). The full `search` contract is in
|
|
@@ -497,19 +487,17 @@ pieces. Compose these — don't hand-roll search:
|
|
|
497
487
|
- **Debounce the term — `enabled` is not a substitute.** `enabled` decides *whether* to ask, not
|
|
498
488
|
*how often*: wire an input's own state into `params` and every keystroke past the first is a
|
|
499
489
|
fresh cache key and a fresh request (and a re-count, where `total` is asked). Keep the input's
|
|
500
|
-
value in one state and debounce the COMMIT into a second (
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
- **`useRecents(key, { max })`** — persist the picked option locally; pass its list as
|
|
505
|
-
`recentOptions` ([./navigation_and_state.md](./navigation_and_state.md)).
|
|
490
|
+
value in one state and debounce the COMMIT into a second (~250 ms); anything reporting on the
|
|
491
|
+
results (an empty state, a count) reads the committed one.
|
|
492
|
+
- **`useRecents(key, { max })`** — persist the picked option locally and offer the list before
|
|
493
|
+
anything is typed ([./navigation_and_state.md](./navigation_and_state.md)).
|
|
506
494
|
|
|
507
495
|
```tsx
|
|
508
496
|
const [typed, setTyped] = useState(""); // the input's own value, every keystroke
|
|
509
497
|
const [term, setTerm] = useState(""); // what the server is asked, once typing settles
|
|
510
|
-
const commit = useDebouncedCallback(setTerm, 250);
|
|
498
|
+
const commit = useDebouncedCallback(setTerm, 250); // any debounce helper
|
|
511
499
|
|
|
512
|
-
<
|
|
500
|
+
<input type="search" value={typed} onChange={(e) => { setTyped(e.target.value); commit(e.target.value.trim()); }} />;
|
|
513
501
|
|
|
514
502
|
const { rows, loading } = useQuery(
|
|
515
503
|
"searchCustomers",
|
|
@@ -529,13 +517,13 @@ relationship exists.
|
|
|
529
517
|
When the user doesn't know the term — "show me everything, let me narrow it" — build a modal table
|
|
530
518
|
they can browse (numbered pages), search, sort, and filter:
|
|
531
519
|
|
|
532
|
-
- **The screen is app-owned
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
520
|
+
- **The screen is app-owned**: a dialog over a search input, filter pills, a table and
|
|
521
|
+
pagination, drawn with whatever UI the app uses over the SDK (which is UI-free). It lives in the
|
|
522
|
+
app (e.g. a `record_picker.tsx`) — reuse it for any table by passing a different `alias` + column
|
|
523
|
+
config.
|
|
536
524
|
- **`useQuery(alias, params, { page: n, sort, filter })`** drives it: the page of rows, the
|
|
537
525
|
`total` and `pageCount` for "Page 1 of N" (counted by default with `page`), and `setPage`.
|
|
538
|
-
- **Filter pills → runtime `filter
|
|
526
|
+
- **Filter pills → runtime `filter`**, one condition per pill;
|
|
539
527
|
**column-header sort → runtime `sort`** by mapping the table's `{ key, order }` to
|
|
540
528
|
`[{ field_key: key, order }]`. Both are server-bounded to the query's output columns — the picker
|
|
541
529
|
can't widen exposure ([./queries.md](./queries.md)).
|
package/docs/files.md
CHANGED
|
@@ -13,7 +13,7 @@ discipline in [data fetching](./data_fetching.md).
|
|
|
13
13
|
| Attach to a record | a declared workflow with a `{ type: "file" }` input | the workflow writes the id(s) into a `files` field — the **only** write path |
|
|
14
14
|
| Read back from records | `useQuery` + `readFiles(cell)` | `AppFile[]` — presigned `url`/`thumbnail_url` (24 h) + `size`/`created_at` |
|
|
15
15
|
| Receive a generated document | `useWorkflow` → `WorkflowResult.files` | presigned files auto-extracted from the run |
|
|
16
|
-
| Show it |
|
|
16
|
+
| Show it | the app's own thumbnail and viewer | `url` / `thumbnail_url` (see below) |
|
|
17
17
|
| Save browser-built bytes | `downloadFile(filename, data, mimeType?)` | a client-side download (see [runtime](./runtime.md)) |
|
|
18
18
|
| Rename one a record holds | `renameFile(file_id, filename)` | an `AppFile` — a new, unattached file over the same bytes |
|
|
19
19
|
|
|
@@ -145,16 +145,14 @@ const { files, add, remove, clear, uploading, fileIds } = useAttachments();
|
|
|
145
145
|
const design = useWorkflow("design");
|
|
146
146
|
|
|
147
147
|
// attach: picking is the app's choice — button, paste, or drop
|
|
148
|
-
<
|
|
148
|
+
<button type="button" onClick={() => pickFiles({ accept: "image/*" }).then(add)}>Attach</button>
|
|
149
149
|
|
|
150
|
-
// preview:
|
|
150
|
+
// preview: the local preview is there before the upload finishes
|
|
151
151
|
{files.map((f) => (
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
onRemove={() => remove(f.id)}
|
|
157
|
-
/>
|
|
152
|
+
<figure key={f.id} aria-busy={f.status === "uploading"}>
|
|
153
|
+
<img src={f.preview_url} alt={f.filename} />
|
|
154
|
+
<button type="button" onClick={() => remove(f.id)}>Remove</button>
|
|
155
|
+
</figure>
|
|
158
156
|
))}
|
|
159
157
|
|
|
160
158
|
// send: gate on `uploading`, payload is `fileIds`
|
|
@@ -174,15 +172,10 @@ const onAdd = async (picked: File[]) => {
|
|
|
174
172
|
await save({ item_id: r.__source_record_id, photo_added: stored });
|
|
175
173
|
};
|
|
176
174
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
filename: f.filename,
|
|
182
|
-
mimeType: f.mime_type,
|
|
183
|
-
previewUrl: f.preview_url,
|
|
184
|
-
status: f.status === "error" ? "error" : "uploading",
|
|
185
|
-
}))}
|
|
175
|
+
// One grid: the stored pile, then the queue still uploading (or failed) beside it.
|
|
176
|
+
<FileGrid
|
|
177
|
+
stored={held} // AppFile[]: url, thumbnail_url, filename, mime_type
|
|
178
|
+
pending={queue.files} // AttachedFile[]: preview_url, status
|
|
186
179
|
onAdd={(picked) => { void onAdd(picked).catch(report); }}
|
|
187
180
|
/>
|
|
188
181
|
```
|
|
@@ -216,13 +209,10 @@ Each `AttachedFile` is `{ id, filename, mime_type, preview_url, status, file_id?
|
|
|
216
209
|
- `file_id` — the stored id, set once `status` is `"ready"`. `fileIds` includes only ready
|
|
217
210
|
entries, so sending while `uploading` is true would silently drop in-flight files — gate on it.
|
|
218
211
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
`AttachedFile` to a `FileThumbnailGrid` `FileUpload` entry — ready -> `{ status: "complete", id:
|
|
224
|
-
file_id, file: <DisplayFile> }`, else `{ status, id, filename, mimeType: mime_type, previewUrl:
|
|
225
|
-
preview_url }` — and the grid renders the uploading/error/retry tiles itself.
|
|
212
|
+
In a composer, trigger picking with `pickFiles`, show each attachment's `preview_url` as above,
|
|
213
|
+
and disable Send while `uploading`. A record's section draws the queue beside the stored pile, so
|
|
214
|
+
the picked file and the files the record holds stand in one grid; an entry whose `status` is
|
|
215
|
+
`"error"` offers remove and re-add.
|
|
226
216
|
|
|
227
217
|
## File cells in query results — `readFiles` and `AppFile`
|
|
228
218
|
|
|
@@ -263,8 +253,8 @@ file. It is pure and never throws.
|
|
|
263
253
|
nothing.
|
|
264
254
|
|
|
265
255
|
- **Thumbnails are optimistic.** `thumbnail_url` is emitted for every image without checking that
|
|
266
|
-
the variant exists; a not-yet-generated variant 404s on fetch.
|
|
267
|
-
falls back to `url` on image error
|
|
256
|
+
the variant exists; a not-yet-generated variant 404s on fetch. An `<img src={thumbnail_url}>`
|
|
257
|
+
falls back to `url` on image error.
|
|
268
258
|
- **A presigned `url` is a bearer credential for the bytes.** It carries its own authorization —
|
|
269
259
|
anyone holding it fetches the file for 24 h with no session — so it must never leave the render
|
|
270
260
|
path: not into a log line, not into telemetry, not into an error report or a bug ticket. Don't
|
|
@@ -360,35 +350,17 @@ over 200 characters, punctuation alone, or the empty string a name expression yi
|
|
|
360
350
|
nothing. `null` is the one absence that is not an error (an empty record field reads as `null`): it
|
|
361
351
|
means "no name", and that entry keeps the stored filename.
|
|
362
352
|
|
|
363
|
-
## Previewing
|
|
353
|
+
## Previewing
|
|
364
354
|
|
|
365
|
-
Render
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
355
|
+
Render a file inline with whatever viewer the app uses — `openExternal` is not a preview (that's
|
|
356
|
+
"open elsewhere", not viewing). What the SDK hands over: `url` (the bytes), `thumbnail_url` (an
|
|
357
|
+
image's smaller variant), `filename` and `mime_type` to choose a viewer by. For an `AttachedFile`
|
|
358
|
+
still uploading, `preview_url` stands in for `url` (there is no server URL yet).
|
|
369
359
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
| `mime_type` | `mimeType` |
|
|
375
|
-
| `url` | `url` |
|
|
376
|
-
| `thumbnail_url` | `thumbnailUrl` |
|
|
377
|
-
|
|
378
|
-
For an `AttachedFile` still uploading, map `preview_url` → `url` (there is no server URL yet).
|
|
379
|
-
|
|
380
|
-
- **`FileThumbnail`** — one square tile; `uploading` takes the queue status and overlays the
|
|
381
|
-
scrim, the spinner or the retry, images fall back from `previewUrl` to `thumbnailUrl` to `url`.
|
|
382
|
-
- **`FileThumbnailGrid`** — a grid of stored files plus a live `uploads` queue, in one grid.
|
|
383
|
-
- **`FileGalleryDialog`** — the full-screen viewer (filename · counter · actions · close, with
|
|
384
|
-
prev/next + ESC), delegating per-file to **`FilePreview`**, which dispatches by MIME. Wire
|
|
385
|
-
`onFilePress` → a `number | null` `activeIndex`.
|
|
386
|
-
- PDF renders **inline to a canvas** (a nested PDF browsing context is blocked in the sandboxed
|
|
387
|
-
iframe; a canvas isn't). The PDF, Word, and Excel/CSV engines ship as `@lotics/ui`
|
|
388
|
-
dependencies — no separate install — and are lazy-loaded, so apps that never preview a type pay
|
|
389
|
-
no bundle cost.
|
|
390
|
-
- The gallery's "open in new tab" action can't pop a window in the sandbox — pass
|
|
391
|
-
`onOpenExternal` wired to the SDK's `openExternal` (omit it and the action hides).
|
|
360
|
+
- **A PDF draws to a canvas.** A nested PDF browsing context is blocked in the sandboxed iframe; a
|
|
361
|
+
canvas isn't, so a PDF viewer has to render pages itself.
|
|
362
|
+
- **"Open in new tab" goes through `openExternal`.** The sandbox drops `window.open`, so an action
|
|
363
|
+
that opens a file elsewhere calls the SDK's `openExternal(url)`.
|
|
392
364
|
|
|
393
365
|
## Filtering on files fields
|
|
394
366
|
|
|
@@ -54,13 +54,12 @@ up table edits with no app change.
|
|
|
54
54
|
|
|
55
55
|
```tsx
|
|
56
56
|
const { fields } = useFieldOptions("orders"); // same alias you query
|
|
57
|
-
// populate
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
/>
|
|
57
|
+
// populate a picker: the option KEY is the value a workflow input takes
|
|
58
|
+
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
|
59
|
+
{(fields.status?.options ?? []).map((o) => (
|
|
60
|
+
<option key={o.key} value={o.key}>{o.label}</option>
|
|
61
|
+
))}
|
|
62
|
+
</select>
|
|
64
63
|
```
|
|
65
64
|
|
|
66
65
|
### What comes back
|
|
@@ -75,8 +74,8 @@ a compile error, and every key is optional. Each `FieldOptions`:
|
|
|
75
74
|
| `options` | Every option of the field — `{ key, label, color, mark? }` (the option's own brand or glyph, where the field marks every option) — in field-config order, **including options not present in any current row** |
|
|
76
75
|
| `byKey(key)` | Resolve one option by key; `undefined` for an unknown key (option removed after the cell was written) |
|
|
77
76
|
|
|
78
|
-
- `color` is a named palette token (e.g. `"blue"`, `"emerald"`)
|
|
79
|
-
|
|
77
|
+
- `color` is a named palette token (e.g. `"blue"`, `"emerald"`), mapped to a color by the app;
|
|
78
|
+
draw a missing or unrecognized token neutral.
|
|
80
79
|
- **A column the server can't map to a single source select field is simply absent** from
|
|
81
80
|
`fields` — a UNION output whose arms disagree on the source field, or a computed column:
|
|
82
81
|
`fields.status?.options ?? []`.
|
|
@@ -91,13 +90,15 @@ A number whose field names a `unit_field` or `currency_field` reads each row in
|
|
|
91
90
|
on that row holds, so a comparison on it states which one as `unit_option` — the server refuses one
|
|
92
91
|
that does not, and keeps only the rows in that unit (a measured unit's kin, `kg` beside `t`,
|
|
93
92
|
converted). `units` is keyed like `fields`: `{ vocabulary: "unit" | "currency", options: { key, label }[] }`
|
|
94
|
-
per such column, whether or not the query projects the select.
|
|
93
|
+
per such column, whether or not the query projects the select. A range filter on such a column
|
|
94
|
+
offers its `options` and puts the picked key on each condition:
|
|
95
95
|
|
|
96
96
|
```tsx
|
|
97
97
|
const { units } = useFieldOptions("loads");
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
const filter = {
|
|
99
|
+
field_key: "weight", operator: "greater_than", value: 10,
|
|
100
|
+
unit_option: unit, // one of units.weight?.options[].key
|
|
101
|
+
};
|
|
101
102
|
```
|
|
102
103
|
|
|
103
104
|
### Caching & freshness
|
|
@@ -112,7 +113,8 @@ until an edit drawer opens). State: `{ fields, units, loading, isValidating, err
|
|
|
112
113
|
|
|
113
114
|
```tsx
|
|
114
115
|
const opt = readSelect(row.status)[0];
|
|
115
|
-
|
|
116
|
+
const shown = opt ? (fields.status?.byKey(opt.key) ?? opt) : null; // carries `color` when known
|
|
117
|
+
{shown ? <Badge color={shown.color} label={shown.label} /> : null}
|
|
116
118
|
```
|
|
117
119
|
|
|
118
120
|
`byKey` hit → the configured color. `byKey` miss (option removed post-write) → fall back to the
|
|
@@ -141,12 +143,11 @@ projected `select_member` cell to `Array<{ id, name, email?, image?, groups?, ro
|
|
|
141
143
|
"Admin" beside a name as rank. If the question your screen asks is "who is this person in the
|
|
142
144
|
company", the answer is `groups`.
|
|
143
145
|
- **`joined` is an ISO timestamp of when the membership began.** Render it at whatever precision
|
|
144
|
-
your question needs —
|
|
145
|
-
the new person?" does not want a day. Absent on a public response, and on an id that did not
|
|
146
|
+
your question needs — "is this the new person?" wants month and year, not a day. Absent on a public response, and on an id that did not
|
|
146
147
|
resolve: there is no membership to have begun.
|
|
147
148
|
- **`archived: true` marks someone who has LEFT** — omitted otherwise, never `false`. It is the one
|
|
148
149
|
field that is NOT gated (a departed colleague reading as a current assignee is wrong on a public
|
|
149
|
-
app too).
|
|
150
|
+
app too). Draw such a person as inactive wherever they appear.
|
|
150
151
|
- **An id that no longer resolves** (removed member, id outside the org) comes back with
|
|
151
152
|
`name: null` — an explicit missing state, never an empty string. Render a placeholder.
|
|
152
153
|
- Only ids already present in the projected rows are resolved — a member cell never exposes the
|
|
@@ -219,11 +220,11 @@ the `select_member` field in the query, and the cell already carries the name.
|
|
|
219
220
|
```tsx
|
|
220
221
|
// No useMembers. The projected cell is the member source.
|
|
221
222
|
{readMembers(row.assignee).map((m) => (
|
|
222
|
-
<
|
|
223
|
+
<span key={m.id}>{m.name ?? "—"}</span>
|
|
223
224
|
))}
|
|
224
225
|
```
|
|
225
226
|
|
|
226
|
-
Names render; only the avatar photo is unavailable
|
|
227
|
+
Names render; only the avatar photo is unavailable, so draw initials in its place. If a name
|
|
227
228
|
renders blank, fix the query projection — never widen the bindings.
|
|
228
229
|
|
|
229
230
|
## `useViewer()` — display-only identity
|
|
@@ -251,11 +252,9 @@ surface that dates or counts by them draws the failure rather than guessing the
|
|
|
251
252
|
|
|
252
253
|
## The workspace's zone at the root
|
|
253
254
|
|
|
254
|
-
An app
|
|
255
|
-
`
|
|
256
|
-
|
|
257
|
-
instant the kit prints (`useFormatDate`) and every day it files by or counts to (`useCalendarDay`)
|
|
258
|
-
is the workspace's, never the browser's. The sandbox starter's `src/main.tsx` does exactly this.
|
|
255
|
+
An app's root draws nothing until `useAppContext()` has `settled` — where it failed, it draws
|
|
256
|
+
`failure` with its `retry` in place of the app — and then dates every instant it prints, and every
|
|
257
|
+
day it files by or counts to, in `useAppContext().timezone`: the workspace's, never the browser's.
|
|
259
258
|
|
|
260
259
|
## `useWorkspaceTimezone()` / `useWorkspaceCurrency()` — what the workspace states
|
|
261
260
|
|
|
@@ -264,11 +263,11 @@ until it resolves, where it failed, and from a host that predates them.
|
|
|
264
263
|
|
|
265
264
|
- **`useWorkspaceTimezone()`** → the workspace's IANA zone (`Asia/Ho_Chi_Minh`). It dates an
|
|
266
265
|
INSTANT — a record's own `__created_at`, a comment's time. A stored `date`/`datetime` cell is a
|
|
267
|
-
wall clock and needs none.
|
|
268
|
-
|
|
266
|
+
wall clock and needs none. Hand it to the root's date formatting once (above), rather than to
|
|
267
|
+
each date call.
|
|
269
268
|
- **`useWorkspaceCurrency()`** → the workspace's ISO 4217 code (`VND`), what money is counted in
|
|
270
269
|
where the field states no code of its own. Pass it as the money's `currency`: a money format
|
|
271
|
-
given no code prints
|
|
270
|
+
given no code prints a default market, which says nothing about this workspace.
|
|
272
271
|
|
|
273
272
|
## Comments: `useComments` / `useCommentCounts`
|
|
274
273
|
|
|
@@ -319,8 +318,8 @@ columns, and a hook cannot be called conditionally, so the guard belongs at the
|
|
|
319
318
|
renders the panel. State: `{ comments, loading, error, available, createComment,
|
|
320
319
|
updateComment, deleteComment, refetch }`.
|
|
321
320
|
|
|
322
|
-
- `comments` — newest first on the wire (server order)
|
|
323
|
-
|
|
321
|
+
- `comments` — newest first on the wire (server order); a thread usually reads oldest-first, so
|
|
322
|
+
reverse it for display. Each `AppComment`: `{ id, record_id,
|
|
324
323
|
table_id, member_id, author, content, files, workspace_id, created_at, updated_at }`. Attachments
|
|
325
324
|
(`AppCommentFile`) carry `id` / `filename` / `mime_type` — a file's identity is its `id`, and
|
|
326
325
|
the server re-reads every attachment from storage by that id, so nothing else you hold about a
|
|
@@ -358,18 +357,6 @@ table's id: read it off a row of the query that renders the badges (`rows[0]?.__
|
|
|
358
357
|
rather than pasting a `tbl_` id, which a copy of the app cannot carry. Pass `undefined` while no
|
|
359
358
|
row is on screen — no request is sent and `counts` is `{}`.
|
|
360
359
|
|
|
361
|
-
## Pairing with `@lotics/ui`
|
|
362
|
-
|
|
363
|
-
The SDK ships zero components; these `@lotics/ui` components (a sandbox session's kit, see
|
|
364
|
-
[AGENTS.md](../AGENTS.md)) are shaped to accept SDK values directly:
|
|
365
|
-
|
|
366
|
-
| Value | Component | Feed it |
|
|
367
|
-
| --- | --- | --- |
|
|
368
|
-
| A select value (stored or picker option) | `Status` | A `useFieldOptions` option, or `byKey(readSelect(cell)[0]?.key)`; accepts a single option, an array (multi → one badge each), or null (renders nothing). Missing/unknown color → neutral. |
|
|
369
|
-
| A person, inline | `MemberChip` | `name` / `image` from a roster or a cell — both carry it; no image → initials |
|
|
370
|
-
| A member picker | `MemberSelect` | `members={useMembers().members}` — renders each option as a `MemberChip`; `MEMBER_UNASSIGNED` marks its optional "unassigned" option |
|
|
371
|
-
| A comment thread | `Timeline` + `Composer` (`@lotics/ui/composer`) | `useComments` state; `resolveMember` reads each comment's own `author` (`{ name, image }`) — no roster needed |
|
|
372
|
-
|
|
373
360
|
## Where each hook works
|
|
374
361
|
|
|
375
362
|
| Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
|
package/docs/mutations.md
CHANGED
|
@@ -40,8 +40,8 @@ Exact signature: `dist/hooks.d.ts`. `useWorkflow(alias)` returns a stable async
|
|
|
40
40
|
render: one callable per alias, each run exactly as `useWorkflow`'s.
|
|
41
41
|
|
|
42
42
|
Typing comes from `.lotics/app_workflows.d.ts`, which augments the SDK's `AppWorkflows` and
|
|
43
|
-
`AppWorkflowResults` interfaces from the app's bound declarations. The declarations are generated from the app's live bindings into `.lotics/`
|
|
44
|
-
|
|
43
|
+
`AppWorkflowResults` interfaces from the app's bound declarations. The declarations are generated from the app's live bindings into `.lotics/` by
|
|
44
|
+
`lotics app create --custom` and `lotics app deploy`. The result:
|
|
45
45
|
|
|
46
46
|
| Declaration | Call-site type |
|
|
47
47
|
|---|---|
|
|
@@ -379,9 +379,7 @@ The same trap one level up: **an action takes an ID, never a captured object.**
|
|
|
379
379
|
`onPress={() => issueInvoice(invoice)}` closes over the invoice as it was when the press
|
|
380
380
|
happened, so no amount of waiting refreshes it — the confirm quotes, and the mint bills, the
|
|
381
381
|
pre-edit total. Pass the key and resolve at use (`onPress={() => setConfirmKey(invoice.key)}`,
|
|
382
|
-
then re-read or `find` where it is consumed).
|
|
383
|
-
(`docs/data_entry.md` § A field that saves itself), which fixes the ordering; the captured value is the app's
|
|
384
|
-
to get right.
|
|
382
|
+
then re-read or `find` where it is consumed).
|
|
385
383
|
|
|
386
384
|
## Diff before update — send only what changed
|
|
387
385
|
|
|
@@ -685,8 +683,8 @@ any alias: stand the other acts down, since the host would refuse them. A record
|
|
|
685
683
|
app or the chat is invisible to this app, so `start` can still be refused while `busy` is false.
|
|
686
684
|
|
|
687
685
|
**Filed means written.** When `state` becomes `filed`, every mounted query re-reads, exactly as
|
|
688
|
-
after a successful `useWorkflow` call ([refetch](#refetch-after-a-mutation))
|
|
689
|
-
files and transcript
|
|
686
|
+
after a successful `useWorkflow` call ([refetch](#refetch-after-a-mutation)), and the filed row's
|
|
687
|
+
files and transcript read like any other.
|
|
690
688
|
|
|
691
689
|
A mock fixture's `recordings` map (alias → `state`) draws each phase without a host
|
|
692
690
|
([runtime](./runtime.md#the-mock-harness-optionsfixture--__mock1)).
|
|
@@ -69,8 +69,7 @@ routes, and splats all work. Inside the tree, use react-router normally:
|
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
`message` takes the path so the words may place it where the language needs
|
|
72
|
-
it.
|
|
73
|
-
SDK ships none of its own.
|
|
72
|
+
it. The SDK ships no words of its own.
|
|
74
73
|
- **Limitation: element routing only.** `AppRouter` mounts a plain browser
|
|
75
74
|
router, not a react-router *data* router — route `loader`/`action` fields
|
|
76
75
|
are ignored, and `useLoaderData` **throws** ("must be used within a data
|
package/docs/runtime.md
CHANGED
|
@@ -279,11 +279,12 @@ predates the two workspace facts answers without them; the recording fields are
|
|
|
279
279
|
context type is not exported from the package root, so type the result yourself
|
|
280
280
|
via the `rpc<T>` generic.
|
|
281
281
|
|
|
282
|
-
An app's root draws nothing until this op has answered or failed, then
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
currency, the screen refuses by name rather than printing one
|
|
282
|
+
An app's root draws nothing until this op has answered or failed, then dates
|
|
283
|
+
every instant it formats in the workspace's zone
|
|
284
|
+
([members & options](./members_and_options.md)); money whose field states no
|
|
285
|
+
code is counted in the workspace's currency. Where neither the field nor the
|
|
286
|
+
host names a currency, the screen refuses by name rather than printing one
|
|
287
|
+
nobody stated.
|
|
287
288
|
|
|
288
289
|
## `openExternal()` — open a link in a new tab
|
|
289
290
|
|
|
@@ -306,9 +307,8 @@ page (standalone). Opens in a new tab with `noopener,noreferrer`.
|
|
|
306
307
|
its own side — a URL handed across the bridge is never trusted.
|
|
307
308
|
- Typical use: opening a workflow-generated file's `url` from
|
|
308
309
|
`WorkflowResult.files[]` (see [mutations](./mutations.md)).
|
|
309
|
-
- **Not a preview mechanism.** To *view* a file inline,
|
|
310
|
-
|
|
311
|
-
"leave the app".
|
|
310
|
+
- **Not a preview mechanism.** To *view* a file inline, render it in the app
|
|
311
|
+
(see [files](./files.md)); `openExternal` is "leave the app".
|
|
312
312
|
|
|
313
313
|
## `openApp()` — the cross-app hop
|
|
314
314
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.105.0",
|
|
4
4
|
"description": "The SDK a Lotics custom-code app reads and writes through \u2014 typed hooks over the host bridge, cell readers, mount() and AppRouter",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|