@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.
@@ -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 ?? "Retry all"} />
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={labels?.upload}
195
+ labels={upload}
184
196
  />
185
197
  )
186
198
  }