@lotics/ui 14.3.1 → 15.1.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/src/agent_run.tsx CHANGED
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
2
2
  import { StyleSheet, View } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
+ import { Button } from "./button";
5
6
  import { Icon, type IconName } from "./icon";
6
7
  import { Markdown } from "./markdown";
7
8
  import { JsonPanel, stringifyData } from "./json_panel";
@@ -14,7 +15,24 @@ import { useLoticsLocale, type LoticsLocale } from "./locale";
14
15
  // The render model comes STRAIGHT from ai-sdk `UIMessage.parts` — no bespoke
15
16
  // transcript type. `agent_transform` is the one place that folds parts into the
16
17
  // timeline; this file is the renderer.
17
- import { toSegments, anyRunning, type AgentUIPart, type AgentStep } from "./agent_transform";
18
+ import { toSegments, anyRunning, type AgentUIPart, type AgentStep, type AgentStepStatus } from "./agent_transform";
19
+
20
+ /** A resolved tool call as a `labelForCall` sees it: the RAW tool name, the
21
+ * input it was invoked with, and its settle state. Richer than a name-only map,
22
+ * so a caller can phrase a call by what it targets or by whether it's awaiting. */
23
+ export interface AgentToolCall {
24
+ toolName: string;
25
+ input: unknown;
26
+ state: AgentStepStatus;
27
+ }
28
+
29
+ /** A settled tool call as `renderToolOutput` sees it — the output stands in for
30
+ * the state (only an output-carrying step reaches the renderer). */
31
+ export interface AgentToolOutput {
32
+ toolName: string;
33
+ input: unknown;
34
+ output: unknown;
35
+ }
18
36
 
19
37
  export interface AgentRunProps {
20
38
  /** The agent message's ai-sdk `parts` — the ordered transcript, rendered in
@@ -30,10 +48,20 @@ export interface AgentRunProps {
30
48
  * it renders as a terminal danger row under the transcript. A per-tool failure
31
49
  * is different — it rides in that tool's own `output-error` part. */
32
50
  error?: string;
33
- /** Localize / override a TOOL step's display label by its raw tool name
34
- * (`update_records` → "Đang cập nhật dữ liệu"). Return `undefined` to fall
51
+ /** Localize / override a TOOL step's display label from the CALL — its raw
52
+ * name, input, and state (`update_records` → "Đang cập nhật dữ liệu"; or a
53
+ * distinct phrasing while `state === "awaiting"`). Return `undefined` to fall
35
54
  * back to the built-in label. */
36
- labelForTool?: (toolName: string) => string | undefined;
55
+ labelForCall?: (call: AgentToolCall) => string | undefined;
56
+ /** Replace the DEFAULT `JsonPanel` output rendering of an expanded tool step —
57
+ * the only render escape hatch. Called only when a step carrying an output is
58
+ * actually EXPANDED; a non-`undefined` return replaces the output panel (an
59
+ * image preview, a table), while `undefined` keeps the default panel. Input +
60
+ * error rendering are unaffected. */
61
+ renderToolOutput?: (call: AgentToolOutput) => ReactNode | undefined;
62
+ /** When the run ended on a terminal `error`, render a retry action under that
63
+ * danger row. Omit it and the error row renders exactly as before. */
64
+ onRetry?: () => void;
37
65
  /** Localized "{n} steps" suffix on a collapsed activity group (the kit is
38
66
  * locale-neutral). Default `"{n} steps"`. */
39
67
  stepsLabel?: (n: number) => string;
@@ -42,7 +70,7 @@ export interface AgentRunProps {
42
70
 
43
71
  // ── Tool → display meta ───────────────────────────────────────────────────────
44
72
  // The bounded set of platform tools an app agent can call. English by default
45
- // (the kit is locale-neutral; apps localize via `labelForTool`). A tool the map
73
+ // (the kit is locale-neutral; apps localize via `labelForCall`). A tool the map
46
74
  // doesn't know falls back to a prettified name + a neutral icon.
47
75
  const TOOL_META: Record<string, { label: string; icon: IconName }> = {
48
76
  query_records: { label: "Searching records", icon: "search" },
@@ -65,23 +93,23 @@ function prettifyToolName(name: string): string {
65
93
  * then the built-in map, then a prettified fallback. Exported so other agent
66
94
  * surfaces (e.g. `AgentProgress`) render the same labels. */
67
95
  export function resolveToolMeta(
68
- toolName: string,
69
- labelForTool?: (toolName: string) => string | undefined,
96
+ call: AgentToolCall,
97
+ labelForCall?: (call: AgentToolCall) => string | undefined,
70
98
  ): { label: string; icon: IconName } {
71
99
  const def =
72
- TOOL_META[toolName] ??
73
- (toolName.startsWith("generate_") && toolName.endsWith("_from_template")
100
+ TOOL_META[call.toolName] ??
101
+ (call.toolName.startsWith("generate_") && call.toolName.endsWith("_from_template")
74
102
  ? { label: "Generating document", icon: "file-text" as IconName }
75
103
  : undefined);
76
104
  return {
77
- label: labelForTool?.(toolName) ?? def?.label ?? prettifyToolName(toolName),
105
+ label: labelForCall?.(call) ?? def?.label ?? prettifyToolName(call.toolName),
78
106
  icon: def?.icon ?? "list",
79
107
  };
80
108
  }
81
109
 
82
- /** Resolve a tool step's display label from its raw tool name. */
83
- function stepLabel(s: AgentStep, labelForTool?: (toolName: string) => string | undefined): string {
84
- return resolveToolMeta(s.toolName, labelForTool).label;
110
+ /** Resolve a tool step's display label from its call. */
111
+ function stepLabel(s: AgentStep, labelForCall?: (call: AgentToolCall) => string | undefined): string {
112
+ return resolveToolMeta({ toolName: s.toolName, input: s.input, state: s.status }, labelForCall).label;
85
113
  }
86
114
 
87
115
  const INK = colors.zinc[700];
@@ -96,7 +124,8 @@ const INK = colors.zinc[700];
96
124
  * Pair with `Composer` + `ChangeReview`.
97
125
  */
98
126
  export function AgentRun(props: AgentRunProps) {
99
- const { parts, labelForTool, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;
127
+ const { parts, labelForCall, renderToolOutput, onRetry, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;
128
+ const locale = useLoticsLocale();
100
129
  const segments = toSegments(parts);
101
130
  const state = props.state ?? (anyRunning(segments) ? "streaming" : "done");
102
131
  const streaming = state === "streaming";
@@ -125,23 +154,32 @@ export function AgentRun(props: AgentRunProps) {
125
154
  active={streaming && i === lastIndex}
126
155
  expanded={!!expanded[seg.id]}
127
156
  onToggle={() => toggle(seg.id)}
128
- labelForTool={labelForTool}
157
+ labelForCall={labelForCall}
158
+ renderToolOutput={renderToolOutput}
129
159
  stepsLabel={stepsLabel}
130
160
  />
131
161
  );
132
162
  })}
133
163
  {/* A run-level BREAKING error terminates the feed with a danger row. It lives
134
- outside `parts` (the stream failed), so the caller passes it explicitly. */}
164
+ outside `parts` (the stream failed), so the caller passes it explicitly.
165
+ A retry action rides UNDER it when the caller can restart the run. */}
135
166
  {props.error ? (
136
- <View style={styles.row}>
137
- <View style={styles.dotCol}>
138
- <Icon name="circle-alert" size={17} color={colors.red[500]} />
139
- </View>
140
- <View style={styles.rowBody}>
141
- <Text size="sm" color="danger">
142
- {props.error}
143
- </Text>
167
+ <View style={{ gap: 8 }}>
168
+ <View style={styles.row}>
169
+ <View style={styles.dotCol}>
170
+ <Icon name="circle-alert" size={17} color={colors.red[500]} />
171
+ </View>
172
+ <View style={styles.rowBody}>
173
+ <Text size="sm" color="danger">
174
+ {props.error}
175
+ </Text>
176
+ </View>
144
177
  </View>
178
+ {onRetry ? (
179
+ <View style={styles.retry}>
180
+ <Button title={locale.agentRun.retry} color="secondary" icon="rotate-ccw" alignSelf="flex-start" onPress={onRetry} />
181
+ </View>
182
+ ) : null}
145
183
  </View>
146
184
  ) : null}
147
185
  </View>
@@ -179,24 +217,37 @@ function ActivityRow(props: {
179
217
  return <View style={styles.row}>{inner}</View>;
180
218
  }
181
219
 
182
- function StepBody({ label }: { label: string }) {
183
- return <Text size="sm">{label}</Text>;
220
+ // The step label, plus a muted {awaiting}" annotation when the call is parked
221
+ // on a human decision (its amber dot already signals the state; the word names it).
222
+ function StepBody({ label, awaiting }: { label: string; awaiting?: string }) {
223
+ return (
224
+ <Text size="sm">
225
+ {label}
226
+ {awaiting ? (
227
+ <Text size="sm" color="muted">
228
+ {" · " + awaiting}
229
+ </Text>
230
+ ) : null}
231
+ </Text>
232
+ );
184
233
  }
185
234
 
186
235
  // The on-demand reveal for a tool step: Input / Output / Error panels auto-built
187
236
  // from the carried I/O; nothing if there's none. Raw I/O stays OUT of the row — only
188
- // shown here, on demand. Panel titles come from the locale.
189
- function stepDetail(s: AgentStep, labels: LoticsLocale["agentRun"]): ReactNode {
237
+ // shown here, on demand. Panel titles come from the locale. A caller's
238
+ // `renderToolOutput` replaces the OUTPUT panel (input + error stay auto-built).
239
+ function stepDetail(s: AgentStep, labels: LoticsLocale["agentRun"], renderToolOutput?: AgentRunProps["renderToolOutput"]): ReactNode {
190
240
  const hasInput = s.input !== undefined;
191
241
  const hasOutput = s.output !== undefined;
192
242
  if (!hasInput && !hasOutput && !s.errorText) return null;
243
+ const customOutput = hasOutput && renderToolOutput ? renderToolOutput({ toolName: s.toolName, input: s.input, output: s.output }) : undefined;
193
244
  return (
194
245
  <View style={{ gap: 8 }}>
195
246
  {hasInput ? <JsonPanel title={labels.input} value={stringifyData(s.input)} /> : null}
196
247
  {s.errorText ? (
197
248
  <JsonPanel title={labels.error} value={s.errorText} />
198
249
  ) : hasOutput ? (
199
- <JsonPanel title={labels.output} value={stringifyData(s.output)} />
250
+ customOutput !== undefined ? customOutput : <JsonPanel title={labels.output} value={stringifyData(s.output)} />
200
251
  ) : null}
201
252
  </View>
202
253
  );
@@ -205,24 +256,28 @@ function stepDetail(s: AgentStep, labels: LoticsLocale["agentRun"]): ReactNode {
205
256
  // A settled step row. When the step carries I/O (or an error) it EXPANDS IN PLACE on
206
257
  // press — the panels roll out under the row (same disclosure pattern as "Thinking"),
207
258
  // never a popover — so an errored call reads like any other step until you open it.
208
- function StepRow({ s, label }: { s: AgentStep; label: string }) {
259
+ // An `awaiting` call wears the amber dot + an "awaiting" annotation, like an error.
260
+ function StepRow({ s, label, renderToolOutput }: { s: AgentStep; label: string; renderToolOutput?: AgentRunProps["renderToolOutput"] }) {
209
261
  const locale = useLoticsLocale();
210
262
  const [open, setOpen] = useState(false);
211
- const detail = stepDetail(s, locale.agentRun);
212
- const marker: StepStatus = s.status === "error" ? "warning" : "done";
213
- if (!detail) {
263
+ // Pressability is decided by a cheap carried-I/O check; the detail (and any
264
+ // caller `renderToolOutput`) is built only once the row is actually opened.
265
+ const hasDetail = s.input !== undefined || s.output !== undefined || !!s.errorText;
266
+ const marker: StepStatus = s.status === "error" || s.status === "awaiting" ? "warning" : "done";
267
+ const awaiting = s.status === "awaiting" ? locale.agentRun.awaiting : undefined;
268
+ if (!hasDetail) {
214
269
  return (
215
270
  <ActivityRow markerStatus={marker}>
216
- <StepBody label={label} />
271
+ <StepBody label={label} awaiting={awaiting} />
217
272
  </ActivityRow>
218
273
  );
219
274
  }
220
275
  return (
221
276
  <View>
222
277
  <ActivityRow markerStatus={marker} onPress={() => setOpen((o) => !o)} accessibilityLabel={label} trailing={chevron(open ? "up" : "down")}>
223
- <StepBody label={label} />
278
+ <StepBody label={label} awaiting={awaiting} />
224
279
  </ActivityRow>
225
- {open ? <View style={styles.rowDetail}>{detail}</View> : null}
280
+ {open ? <View style={styles.rowDetail}>{stepDetail(s, locale.agentRun, renderToolOutput)}</View> : null}
226
281
  </View>
227
282
  );
228
283
  }
@@ -274,17 +329,29 @@ function ToolGroup(props: {
274
329
  active: boolean;
275
330
  expanded: boolean;
276
331
  onToggle: () => void;
277
- labelForTool?: (toolName: string) => string | undefined;
332
+ labelForCall?: (call: AgentToolCall) => string | undefined;
333
+ renderToolOutput?: AgentRunProps["renderToolOutput"];
278
334
  stepsLabel: (n: number) => string;
279
335
  }) {
280
- const { steps, active, expanded, onToggle, labelForTool, stepsLabel } = props;
281
- const resolve = (s: AgentStep) => stepLabel(s, labelForTool);
336
+ const { steps, active, expanded, onToggle, labelForCall, renderToolOutput, stepsLabel } = props;
337
+ const locale = useLoticsLocale();
338
+ const resolve = (s: AgentStep) => stepLabel(s, labelForCall);
282
339
 
283
340
  // ACTIVE — one pulsing row whose label swaps in place as each call fires (the
284
341
  // label is keyed by the current step's id, so a new call rises + fades into the
285
- // SAME row instead of stacking a new dot).
342
+ // SAME row instead of stacking a new dot). BUT a call parked on a human decision
343
+ // (`awaiting`) is NOT working — it settles to the amber, non-pulsing awaiting row.
286
344
  if (active) {
287
345
  const current = steps[steps.length - 1];
346
+ if (current.status === "awaiting") {
347
+ return (
348
+ <View style={styles.group}>
349
+ <ActivityRow markerStatus="warning">
350
+ <StepBody label={resolve(current)} awaiting={locale.agentRun.awaiting} />
351
+ </ActivityRow>
352
+ </View>
353
+ );
354
+ }
288
355
  return (
289
356
  <View style={styles.group}>
290
357
  <ActivityRow markerStatus="current" live>
@@ -297,6 +364,7 @@ function ToolGroup(props: {
297
364
  }
298
365
 
299
366
  const errored = steps.some((s) => s.status === "error");
367
+ const awaiting = steps.some((s) => s.status === "awaiting");
300
368
  const final = steps[steps.length - 1];
301
369
  const expandable = steps.length > 1;
302
370
 
@@ -304,7 +372,7 @@ function ToolGroup(props: {
304
372
  if (!expandable) {
305
373
  return (
306
374
  <View style={styles.group}>
307
- <StepRow s={final} label={resolve(final)} />
375
+ <StepRow s={final} label={resolve(final)} renderToolOutput={renderToolOutput} />
308
376
  </View>
309
377
  );
310
378
  }
@@ -317,7 +385,7 @@ function ToolGroup(props: {
317
385
  return (
318
386
  <View style={styles.group}>
319
387
  <ActivityRow
320
- markerStatus={errored ? "warning" : "complete"}
388
+ markerStatus={errored || awaiting ? "warning" : "complete"}
321
389
  onPress={onToggle}
322
390
  accessibilityLabel={`${resolve(final)} — ${stepsLabel(steps.length)}`}
323
391
  trailing={chevron(expanded ? "up" : "down")}
@@ -327,7 +395,7 @@ function ToolGroup(props: {
327
395
  {expanded
328
396
  ? steps.map((s) => (
329
397
  <AnimationFadeIn key={s.id} translateY={4}>
330
- <StepRow s={s} label={resolve(s)} />
398
+ <StepRow s={s} label={resolve(s)} renderToolOutput={renderToolOutput} />
331
399
  </AnimationFadeIn>
332
400
  ))
333
401
  : null}
@@ -360,4 +428,7 @@ const styles = StyleSheet.create({
360
428
  reasoning: { paddingLeft: 28, paddingBottom: 4 },
361
429
  // A step's expanded I/O panels — same left edge as the reasoning body.
362
430
  rowDetail: { paddingLeft: 28, paddingBottom: 6, paddingTop: 2 },
431
+ // The retry action under the terminal error — aligned to the message body
432
+ // (past the dot column + its gap), so it reads as a response to that row.
433
+ retry: { paddingLeft: 28 },
363
434
  });
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { toSegments, anyRunning, lastRunningStep, type AgentUIPart, type AgentSegment, type AgentStep } from "./agent_transform";
3
+
4
+ // RN-free logic test for the parts → timeline fold. The renderer (agent_run.tsx)
5
+ // is verified visually in the gallery; the mapping — ai's 7-state tool machine to
6
+ // the feed's 4 states, plus grouping — is unit-tested here. Fixtures are the ai-sdk
7
+ // `dynamic-tool` part shape (contextually typed as `AgentUIPart`, so each concrete
8
+ // `state` discriminates to the right member — no casts).
9
+
10
+ function groups(parts: AgentUIPart[]): Extract<AgentSegment, { kind: "group" }>[] {
11
+ return toSegments(parts).filter((s): s is Extract<AgentSegment, { kind: "group" }> => s.kind === "group");
12
+ }
13
+
14
+ function onlyStep(parts: AgentUIPart[]): AgentStep {
15
+ const [group, ...rest] = groups(parts);
16
+ expect(rest).toHaveLength(0);
17
+ expect(group.steps).toHaveLength(1);
18
+ return group.steps[0];
19
+ }
20
+
21
+ describe("agent_transform — tool state mapping", () => {
22
+ it("input-streaming → running", () => {
23
+ expect(onlyStep([{ type: "dynamic-tool", toolName: "update_records", toolCallId: "t", state: "input-streaming", input: {} }]).status).toBe("running");
24
+ });
25
+
26
+ it("input-available → running", () => {
27
+ expect(onlyStep([{ type: "dynamic-tool", toolName: "update_records", toolCallId: "t", state: "input-available", input: {} }]).status).toBe("running");
28
+ });
29
+
30
+ it("approval-requested → awaiting (parked on a human decision)", () => {
31
+ const step = onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "approval-requested", input: { table: "Containers" }, approval: { id: "a1" } }]);
32
+ expect(step.status).toBe("awaiting");
33
+ // The input still rides along for the on-demand peek.
34
+ expect(step.input).toEqual({ table: "Containers" });
35
+ });
36
+
37
+ it("approval-responded → running (the decision is in, work resumes)", () => {
38
+ expect(onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "approval-responded", input: {}, approval: { id: "a1", approved: true } }]).status).toBe("running");
39
+ });
40
+
41
+ it("output-available → done, carrying the output", () => {
42
+ const step = onlyStep([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "output-available", input: { q: 1 }, output: { rows: 3 } }]);
43
+ expect(step.status).toBe("done");
44
+ expect(step.output).toEqual({ rows: 3 });
45
+ });
46
+
47
+ it("output-error → error, carrying errorText", () => {
48
+ const step = onlyStep([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "output-error", input: {}, errorText: "Unknown field" }]);
49
+ expect(step.status).toBe("error");
50
+ expect(step.errorText).toBe("Unknown field");
51
+ });
52
+
53
+ it("output-denied → error, with the denial reason as the error text", () => {
54
+ const step = onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "output-denied", input: {}, approval: { id: "a1", approved: false, reason: "Delete not permitted" } }]);
55
+ expect(step.status).toBe("error");
56
+ expect(step.errorText).toBe("Delete not permitted");
57
+ });
58
+
59
+ it("output-denied without a reason → error with no error text", () => {
60
+ const step = onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "output-denied", input: {}, approval: { id: "a1", approved: false } }]);
61
+ expect(step.status).toBe("error");
62
+ expect(step.errorText).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe("agent_transform — streaming signals", () => {
67
+ it("anyRunning is true while a call runs, false once every call has settled", () => {
68
+ const running = toSegments([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "input-available", input: {} }]);
69
+ expect(anyRunning(running)).toBe(true);
70
+
71
+ const settled = toSegments([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "output-available", input: {}, output: {} }]);
72
+ expect(anyRunning(settled)).toBe(false);
73
+ });
74
+
75
+ it("an awaiting call is NOT running — it's parked, so the feed never pulses it", () => {
76
+ const parked = toSegments([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "approval-requested", input: {}, approval: { id: "a1" } }]);
77
+ expect(anyRunning(parked)).toBe(false);
78
+ expect(lastRunningStep(parked)).toBeUndefined();
79
+ });
80
+ });
81
+
82
+ describe("agent_transform — grouping", () => {
83
+ it("folds consecutive tool parts into ONE group, split by prose", () => {
84
+ const parts: AgentUIPart[] = [
85
+ { type: "text", text: "First" },
86
+ { type: "dynamic-tool", toolName: "query_records", toolCallId: "a", state: "output-available", input: {}, output: {} },
87
+ { type: "dynamic-tool", toolName: "query_records", toolCallId: "b", state: "output-available", input: {}, output: {} },
88
+ { type: "text", text: "Then" },
89
+ { type: "dynamic-tool", toolName: "delete_records", toolCallId: "c", state: "approval-requested", input: {}, approval: { id: "a1" } },
90
+ ];
91
+ expect(toSegments(parts).map((s) => s.kind)).toEqual(["text", "group", "text", "group"]);
92
+ const [first, second] = groups(parts);
93
+ expect(first.steps.map((s) => s.id)).toEqual(["a", "b"]);
94
+ expect(second.steps.map((s) => s.status)).toEqual(["awaiting"]);
95
+ });
96
+ });
@@ -10,8 +10,11 @@ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
10
10
  /** An ai-sdk message part, tool-set-agnostic (we only read `type` + a few fields). */
11
11
  export type AgentUIPart = UIMessagePart<UIDataTypes, UITools>;
12
12
 
13
- /** A tool step's settle state, collapsed from ai's 7-state tool machine. */
14
- export type AgentStepStatus = "running" | "done" | "error";
13
+ /** A tool step's settle state, folded from ai's 7-state tool machine.
14
+ * `awaiting` = the call is parked on a human decision (`approval-requested`) —
15
+ * first-class, distinct from `running` (working) so the feed never pulses a
16
+ * parked call. */
17
+ export type AgentStepStatus = "running" | "awaiting" | "done" | "error";
15
18
 
16
19
  /** One tool call, reduced to what the feed shows. `toolName` is the RAW name — the
17
20
  * renderer maps it to a human label + icon (`resolveToolMeta`). */
@@ -38,11 +41,15 @@ function isToolPart(part: AgentUIPart): part is Extract<AgentUIPart, { toolCallI
38
41
  return part.type === "dynamic-tool" || part.type.startsWith("tool-");
39
42
  }
40
43
 
41
- // ai's tool state machine → the feed's 3 states. Approval-pending/responded read as
42
- // "running" (a read-only feed has no approval affordance); denied reads as an error.
44
+ // ai's 7-state tool machine → the feed's 4 states. `approval-requested` is
45
+ // first-class `awaiting` (the call sits parked on a human decision);
46
+ // `approval-responded` reads `running` until the resolved output arrives —
47
+ // `output-available` on approve, `output-denied` (an error carrying the denial
48
+ // reason) on deny. `input-streaming`/`input-available` are `running`.
43
49
  function toStatus(state: string): AgentStepStatus {
44
50
  if (state === "output-available") return "done";
45
51
  if (state === "output-error" || state === "output-denied") return "error";
52
+ if (state === "approval-requested") return "awaiting";
46
53
  return "running";
47
54
  }
48
55
 
@@ -53,7 +60,12 @@ function toStep(part: Extract<AgentUIPart, { toolCallId: string }>): AgentStep {
53
60
  status: toStatus(part.state),
54
61
  input: part.input,
55
62
  output: part.state === "output-available" ? part.output : undefined,
56
- errorText: part.state === "output-error" ? part.errorText : undefined,
63
+ errorText:
64
+ part.state === "output-error"
65
+ ? part.errorText
66
+ : part.state === "output-denied"
67
+ ? part.approval.reason
68
+ : undefined,
57
69
  };
58
70
  }
59
71
 
@@ -0,0 +1,70 @@
1
+ import { type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors } from "./colors";
4
+ import { Text } from "./text";
5
+ import { Button } from "./button";
6
+ import { Icon } from "./icon";
7
+ import { useLoticsLocale } from "./locale";
8
+
9
+ export interface ApprovalPromptProps {
10
+ /** Confirm the gated action — the agent proceeds. */
11
+ onApprove: () => void;
12
+ /** Reject the gated action — the agent does not proceed. */
13
+ onDeny: () => void;
14
+ /** Per-instance override of the prompt line (e.g. a browser-specific
15
+ * phrasing); defaults to the `approvalPrompt` locale slice's `message`. */
16
+ message?: string;
17
+ /** Optional detail rendered between the message and the actions — e.g. a
18
+ * summary of the input the agent is asking to run. */
19
+ children?: ReactNode;
20
+ }
21
+
22
+ /**
23
+ * The prompt that ANSWERS an agent's approval-gated action — the interactive
24
+ * counterpart to `AgentRun`'s read-only amber `awaiting` row. A warning icon +
25
+ * message on a bordered card, with Deny (secondary) + Approve (primary)
26
+ * right-aligned; the `children` slot renders an input summary between the two.
27
+ * Every string resolves prop → the `approvalPrompt` locale slice → English.
28
+ * Typically slotted where the composer sits while the run is parked.
29
+ */
30
+ export function ApprovalPrompt(props: ApprovalPromptProps) {
31
+ const { onApprove, onDeny, message, children } = props;
32
+ const labels = useLoticsLocale().approvalPrompt;
33
+
34
+ return (
35
+ <View style={styles.container}>
36
+ <View style={styles.content}>
37
+ <Icon name="shield-alert" size={20} color={colors.orange[600]} />
38
+ <Text size="sm" style={{ flex: 1 }}>
39
+ {message ?? labels.message}
40
+ </Text>
41
+ </View>
42
+ {children}
43
+ <View style={styles.actions}>
44
+ <Button title={labels.deny} color="secondary" onPress={onDeny} />
45
+ <Button title={labels.approve} color="primary" onPress={onApprove} />
46
+ </View>
47
+ </View>
48
+ );
49
+ }
50
+
51
+ const styles = StyleSheet.create({
52
+ container: {
53
+ padding: 12,
54
+ borderRadius: 16,
55
+ borderWidth: 2,
56
+ borderColor: colors.border,
57
+ backgroundColor: colors.background,
58
+ gap: 12,
59
+ },
60
+ content: {
61
+ flexDirection: "row",
62
+ alignItems: "center",
63
+ gap: 8,
64
+ },
65
+ actions: {
66
+ flexDirection: "row",
67
+ justifyContent: "flex-end",
68
+ gap: 8,
69
+ },
70
+ });
@@ -0,0 +1,14 @@
1
+ import type { RegionRef } from "./file_intake";
2
+
3
+ /**
4
+ * Native stand-in for the region → DOM-node resolver.
5
+ *
6
+ * There is no DOM on React Native, so there is no node to resolve — the drag
7
+ * and focus-routing paths this backs are web-only. The file exists purely as
8
+ * the typecheck-resolution base for the `.web` variant (the `.web` file is the
9
+ * only importer at runtime; Metro never reaches this on native, but the shared
10
+ * signature lives here).
11
+ */
12
+ export function resolveRegionNode(_region: RegionRef | undefined): HTMLElement | null {
13
+ return null;
14
+ }
@@ -0,0 +1,13 @@
1
+ import type { RegionRef } from "./file_intake";
2
+
3
+ /**
4
+ * A region ref → its DOM node. RN-web exposes a `View`'s underlying
5
+ * `HTMLElement` straight through the ref's `.current`, so this is the SINGLE
6
+ * place that unwraps it — shared by `FileDropTarget`'s drag listeners and
7
+ * `usePasteFiles`'s focus routing, so the two can never resolve the region two
8
+ * different ways. Returns null when the ref is empty or not yet an element.
9
+ */
10
+ export function resolveRegionNode(region: RegionRef | undefined): HTMLElement | null {
11
+ const node = region?.current;
12
+ return node instanceof HTMLElement ? node : null;
13
+ }
@@ -0,0 +1,16 @@
1
+ import { View } from "react-native";
2
+ import type { FileDropTargetProps } from "./file_intake";
3
+
4
+ /**
5
+ * Native stand-in for the drop region.
6
+ *
7
+ * React Native has no HTML5 drag-and-drop, so the region is simply its children
8
+ * and the surface's own Add-file CTA stays the intake path. The wrapper `View`
9
+ * is kept (and `style` still applies) so the tree and layout match web exactly.
10
+ *
11
+ * Web targets resolve `file_drop_target.web.tsx` instead (Metro's `.web`
12
+ * extension resolution; the package's `react-native` export condition).
13
+ */
14
+ export function FileDropTarget({ children, style }: FileDropTargetProps) {
15
+ return <View style={style}>{typeof children === "function" ? children(false) : children}</View>;
16
+ }