@lotics/ui 20.1.0 → 21.0.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 -2
- package/MIGRATION.md +57 -0
- package/docs/ai_patterns.md +38 -0
- package/docs/catalog.md +42 -21
- package/docs/data_entry.md +55 -23
- package/examples/tpl_item_list.tsx +55 -50
- package/examples/tpl_task_board.tsx +11 -2
- package/package.json +2 -1
- package/src/agent_run_pane.tsx +134 -0
- package/src/file_grid.tsx +14 -2
- package/src/files_editor.tsx +241 -132
- package/src/locale.tsx +74 -0
- package/src/uploading_thumbnail.tsx +5 -3
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useCallback, useMemo, type ReactNode } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { AgentRun, type AgentRunProps } from "./agent_run";
|
|
4
|
+
import type { AgentUIPart } from "./agent_transform";
|
|
5
|
+
import { ClarifyWizard, ClarifyWizardActions, ClarifyWizardScope, type ClarifyWizardAnswer } from "./clarify_wizard";
|
|
6
|
+
import { DialogScrollArea } from "./dialog";
|
|
7
|
+
import { FollowScroll } from "./follow_scroll";
|
|
8
|
+
import { useScreenSize } from "@lotics/ui/use_screen_size";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What this pair needs from a run — structurally the shape `useAgentRun()`
|
|
12
|
+
* returns, declared here rather than imported so `@lotics/ui` keeps its one-way
|
|
13
|
+
* boundary and never depends on `@lotics/app-sdk`. Nothing enforces the match at
|
|
14
|
+
* build time; the app is where the two meet, so its typecheck is the detector.
|
|
15
|
+
*/
|
|
16
|
+
/** One option the agent offered. The answer's `value` IS the label — that is the
|
|
17
|
+
* `ask_user_choice` wire shape, not a convenience. */
|
|
18
|
+
export interface AgentRunOption {
|
|
19
|
+
label: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One question the agent asked, exactly as `pendingChoice` carries it. Named so
|
|
24
|
+
* a consumer can DECLARE one — a template or test builds these directly, and
|
|
25
|
+
* without a name it would borrow `ClarifyWizardQuestion` and map between two
|
|
26
|
+
* shapes that a real run never maps between. */
|
|
27
|
+
export interface AgentRunQuestion {
|
|
28
|
+
question: string;
|
|
29
|
+
options: AgentRunOption[];
|
|
30
|
+
allow_custom?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AgentRunLike {
|
|
34
|
+
status: "idle" | "streaming" | "awaiting_input" | "completed" | "error";
|
|
35
|
+
parts: readonly AgentUIPart[];
|
|
36
|
+
/** Non-null exactly while parked. */
|
|
37
|
+
pendingChoice: { questions: AgentRunQuestion[] } | null;
|
|
38
|
+
answerChoice: (answers: { value: string; custom: boolean }[]) => Promise<unknown>;
|
|
39
|
+
error: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function AgentRunScope({ children }: { children: ReactNode }) {
|
|
43
|
+
return <ClarifyWizardScope>{children}</ClarifyWizardScope>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AgentRunPaneProps {
|
|
47
|
+
run: AgentRunLike;
|
|
48
|
+
labelForCall?: AgentRunProps["labelForCall"];
|
|
49
|
+
renderToolOutput?: AgentRunProps["renderToolOutput"];
|
|
50
|
+
/** Abandon a parked question — the run is left for the operator to retry. */
|
|
51
|
+
onCancel: () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The run's content: the streaming transcript, or — once the agent asks — the
|
|
56
|
+
* question IN ITS PLACE.
|
|
57
|
+
*
|
|
58
|
+
* The question REPLACES the feed rather than stacking under it. The run is
|
|
59
|
+
* blocked on the answer, so the question is the only thing to act on; and
|
|
60
|
+
* stacking is what broke it in production — the wizard sat below the scroller in
|
|
61
|
+
* a container that could not scroll, so a multi-question ask ran past the
|
|
62
|
+
* dialog's height and clipped its own Submit. Every app rebuilt this arrangement
|
|
63
|
+
* by hand and it only had to be got wrong once.
|
|
64
|
+
*/
|
|
65
|
+
export function AgentRunPane(props: AgentRunPaneProps) {
|
|
66
|
+
const { run, labelForCall, renderToolOutput, onCancel } = props;
|
|
67
|
+
const { small } = useScreenSize();
|
|
68
|
+
const pending = run.pendingChoice;
|
|
69
|
+
|
|
70
|
+
// Mapped once per question set, not per render: the wizard keys its per-step
|
|
71
|
+
// draft off identity, and a fresh array every render is a new identity.
|
|
72
|
+
const questions = useMemo(
|
|
73
|
+
() =>
|
|
74
|
+
(pending?.questions ?? []).map((q) => ({
|
|
75
|
+
question: q.question,
|
|
76
|
+
answers: q.options.map((o) => ({ value: o.label, label: o.label, description: o.description ?? "" })),
|
|
77
|
+
allowCustom: q.allow_custom === true,
|
|
78
|
+
})),
|
|
79
|
+
[pending],
|
|
80
|
+
);
|
|
81
|
+
const submit = useCallback(
|
|
82
|
+
(answers: ClarifyWizardAnswer[]) => {
|
|
83
|
+
void run.answerChoice(answers.map((a) => ({ value: a.value, custom: a.custom })));
|
|
84
|
+
},
|
|
85
|
+
[run],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (pending) {
|
|
89
|
+
return (
|
|
90
|
+
<DialogScrollArea>
|
|
91
|
+
<View style={{ paddingVertical: 4 }}>
|
|
92
|
+
<ClarifyWizard questions={questions} onSubmit={submit} onCancel={onCancel} />
|
|
93
|
+
</View>
|
|
94
|
+
</DialogScrollArea>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// FollowScroll, not DialogScrollArea: the transcript grows from the bottom and
|
|
99
|
+
// the newest step has to stay in view. Swapping the scroller on the park is
|
|
100
|
+
// deliberate — remounting opens the question at the top rather than wherever
|
|
101
|
+
// the feed happened to be scrolled to.
|
|
102
|
+
return (
|
|
103
|
+
<FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: small ? 16 : 24 }}>
|
|
104
|
+
{/* No empty-state slot on purpose. `AgentRun` renders its own breathing
|
|
105
|
+
"Starting…" row while streaming with zero parts, localized through the
|
|
106
|
+
`agentRun` locale slice — and ai_patterns states the law outright:
|
|
107
|
+
never hand-roll a placeholder in front of the feed. A slot here would
|
|
108
|
+
invite exactly that, and every app would localize it again. */}
|
|
109
|
+
<AgentRun
|
|
110
|
+
parts={run.parts}
|
|
111
|
+
state={run.status === "error" ? "error" : run.status === "streaming" ? "streaming" : "done"}
|
|
112
|
+
error={run.error ?? undefined}
|
|
113
|
+
labelForCall={labelForCall}
|
|
114
|
+
renderToolOutput={renderToolOutput}
|
|
115
|
+
/>
|
|
116
|
+
</FollowScroll>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The run's action bar, for a `DialogFooter`. Renders the wizard's
|
|
122
|
+
* Cancel/Back/Next/Submit while a question is up and NOTHING otherwise, so the
|
|
123
|
+
* host can mount it unconditionally and let the run decide.
|
|
124
|
+
*
|
|
125
|
+
* Pinned outside the scroller is the point: no length of question can push
|
|
126
|
+
* Submit out of reach.
|
|
127
|
+
*/
|
|
128
|
+
export function AgentRunActions({ run }: { run: AgentRunLike }) {
|
|
129
|
+
// Reads the SAME run the pane reads, rather than state the pane publishes: two
|
|
130
|
+
// components deriving from one source cannot disagree, and nothing has to set
|
|
131
|
+
// a parent's state during a child's render to keep them in step.
|
|
132
|
+
if (run.pendingChoice == null) return null;
|
|
133
|
+
return <ClarifyWizardActions />;
|
|
134
|
+
}
|
package/src/file_grid.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
|
|
|
16
16
|
import { ThumbnailGrid } from "./file_thumbnail_grid";
|
|
17
17
|
import { UploadingThumbnail, type UploadStatus, type UploadStatusLabels } from "./uploading_thumbnail";
|
|
18
18
|
import { Button } from "./button";
|
|
19
|
+
import { useLoticsLocale } from "./locale";
|
|
19
20
|
|
|
20
21
|
/** An in-flight upload item (everything except a completed file). */
|
|
21
22
|
export interface PendingUpload {
|
|
@@ -31,6 +32,9 @@ export interface PendingUpload {
|
|
|
31
32
|
* upload. The host maps its own upload state to this. */
|
|
32
33
|
export type FileUpload = { status: "complete"; id: string; file: DisplayFile } | PendingUpload;
|
|
33
34
|
|
|
35
|
+
/** Per-instance overrides. Both fall back to the active `LoticsLocale`
|
|
36
|
+
* (`fileUpload`), so an app localizes this grid — including the one inside
|
|
37
|
+
* `FilesEditor` — by supplying its pack once at the root, not per call site. */
|
|
34
38
|
export interface FileGridLabels {
|
|
35
39
|
/** "Retry all" footer button (shown when any upload errored). */
|
|
36
40
|
retryAll?: string;
|
|
@@ -126,6 +130,14 @@ export function FileGrid(props: FileGridProps) {
|
|
|
126
130
|
labels,
|
|
127
131
|
} = props;
|
|
128
132
|
|
|
133
|
+
// prop → locale → (nothing) — the locale pack is always complete, so an app
|
|
134
|
+
// that never passes `labels` still reads its own language on an upload that
|
|
135
|
+
// stalls or dies. Hardcoded English here is what made "Upload failed" surface
|
|
136
|
+
// inside a fully-Vietnamese app, since `FilesEditor` — the way most apps reach
|
|
137
|
+
// this grid — has no way to pass `labels` down.
|
|
138
|
+
const loc = useLoticsLocale().fileUpload;
|
|
139
|
+
const upload = { ...loc, ...labels?.upload };
|
|
140
|
+
|
|
129
141
|
const renderItems = buildRenderItems(files, uploads);
|
|
130
142
|
|
|
131
143
|
const handleFilePress = useCallback(
|
|
@@ -142,7 +154,7 @@ export function FileGrid(props: FileGridProps) {
|
|
|
142
154
|
|
|
143
155
|
const retryAll =
|
|
144
156
|
onRetryAll && renderItems.some((item) => item.kind === "upload" && item.upload.status === "error") ? (
|
|
145
|
-
<Button onPress={onRetryAll} color="danger" title={labels?.retryAll ??
|
|
157
|
+
<Button onPress={onRetryAll} color="danger" title={labels?.retryAll ?? loc.retryAll} />
|
|
146
158
|
) : null;
|
|
147
159
|
|
|
148
160
|
return (
|
|
@@ -180,7 +192,7 @@ export function FileGrid(props: FileGridProps) {
|
|
|
180
192
|
size={size}
|
|
181
193
|
onRemove={onUploadRemove ? () => onUploadRemove(item.removeId) : undefined}
|
|
182
194
|
onRetry={onRetry ? () => onRetry(item.removeId) : undefined}
|
|
183
|
-
labels={
|
|
195
|
+
labels={upload}
|
|
184
196
|
/>
|
|
185
197
|
)
|
|
186
198
|
}
|