@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/AGENTS.md +12 -28
- package/MIGRATION.md +66 -0
- package/docs/ai_patterns.md +38 -16
- package/docs/catalog.md +71 -14
- package/docs/data_entry.md +53 -2
- package/docs/templates.md +15 -2
- package/examples/tpl_item_list.tsx +146 -48
- package/examples/tpl_record.tsx +37 -12
- package/package.json +13 -1
- package/src/agent_progress.tsx +11 -7
- package/src/agent_run.tsx +114 -43
- package/src/agent_transform.test.ts +96 -0
- package/src/agent_transform.ts +17 -5
- package/src/approval_prompt.tsx +70 -0
- package/src/dom_region.ts +14 -0
- package/src/dom_region.web.ts +13 -0
- package/src/file_drop_target.tsx +16 -0
- package/src/file_drop_target.web.tsx +118 -0
- package/src/file_dropzone.tsx +51 -96
- package/src/file_intake.test.ts +124 -0
- package/src/file_intake.ts +148 -0
- package/src/locale.tsx +21 -8
- package/src/message_actions.tsx +111 -0
- package/src/use_paste_files.ts +14 -0
- package/src/use_paste_files.web.ts +101 -0
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
|
|
34
|
-
* (`update_records` → "Đang cập nhật dữ liệu"
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
69
|
-
|
|
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:
|
|
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
|
|
83
|
-
function stepLabel(s: AgentStep,
|
|
84
|
-
return resolveToolMeta(s.toolName,
|
|
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,
|
|
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
|
-
|
|
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={
|
|
137
|
-
<View style={styles.
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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}>{
|
|
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
|
-
|
|
332
|
+
labelForCall?: (call: AgentToolCall) => string | undefined;
|
|
333
|
+
renderToolOutput?: AgentRunProps["renderToolOutput"];
|
|
278
334
|
stepsLabel: (n: number) => string;
|
|
279
335
|
}) {
|
|
280
|
-
const { steps, active, expanded, onToggle,
|
|
281
|
-
const
|
|
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
|
+
});
|
package/src/agent_transform.ts
CHANGED
|
@@ -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,
|
|
14
|
-
|
|
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
|
|
42
|
-
//
|
|
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:
|
|
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
|
+
}
|