@lotics/ui 13.9.0 → 14.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/src/agent_run.tsx CHANGED
@@ -5,59 +5,31 @@ import { Text } from "./text";
5
5
  import { Icon, type IconName } from "./icon";
6
6
  import { Markdown } from "./markdown";
7
7
  import { JsonPanel, stringifyData } from "./json_panel";
8
- import { Peek } from "./peek";
9
8
  import { PressableHighlight } from "./pressable_highlight";
10
9
  import { Marker, type StepStatus } from "./stepper";
11
10
  import { AnimationFadeIn } from "./animation_fade_in";
12
11
  import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
13
12
  import { useLoticsLocale, type LoticsLocale } from "./locale";
14
13
 
15
- export type AgentStepStatus = "running" | "done" | "error";
16
-
17
- export interface AgentRunStep {
18
- id: string;
19
- /** A `tool` step carries the RAW tool name (`update_records`) — the feed maps
20
- * it to a human label via the default map / `labelForTool`. A `step` carries
21
- * an already-human label. */
22
- label: string;
23
- status: AgentStepStatus;
24
- kind?: "step" | "tool";
25
- /** The tool call's input (arguments) and result. When present (and no explicit
26
- * `peek`), the feed reveals them ON DEMAND — a press-to-open peek with an Input /
27
- * Output panel — instead of cluttering the row; the row itself shows only the
28
- * label + state. */
29
- input?: unknown;
30
- output?: unknown;
31
- /** The tool failure message (shown in the peek when `status` is `"error"`). */
32
- errorText?: string;
33
- /** A note under the label — a short, human summary (a count, a result summary),
34
- * never invented prose. Muted. For raw tool I/O prefer `input`/`output`. */
35
- detail?: string;
36
- /** Custom content for the press-to-open peek. Overrides the auto-built Input /
37
- * Output panel — pass this to render the reveal yourself. */
38
- peek?: ReactNode;
39
- }
40
-
41
- /**
42
- * The ordered transcript of a run, in the order it happened: the agent's answer
43
- * prose (`text`), its thinking (`reasoning`, shown collapsed / revealed on demand),
44
- * and the tools it runs between prose (`step`). A real run is a timeline — text, a
45
- * burst of tool calls, more text — so the feed is an ordered array, NOT a text
46
- * block over a flat step list. Consecutive `step` items collapse into ONE activity
47
- * group; a `step`'s `input`/`output` reveal in a peek on press.
48
- */
49
- export type AgentRunItem =
50
- | { type: "text"; id: string; text: string }
51
- | { type: "reasoning"; id: string; text: string }
52
- | ({ type: "step" } & AgentRunStep);
14
+ // The render model comes STRAIGHT from ai-sdk `UIMessage.parts` — no bespoke
15
+ // transcript type. `agent_transform` is the one place that folds parts into the
16
+ // timeline; this file is the renderer.
17
+ import { toSegments, anyRunning, type AgentUIPart, type AgentStep } from "./agent_transform";
53
18
 
54
19
  export interface AgentRunProps {
55
- /** The ordered transcript text + step items in the order they occurred. */
56
- items: AgentRunItem[];
57
- /** Whole-run state. `streaming` keeps the trailing text's caret blinking and
58
- * the tail activity group pulsing; `done`/`error` settle every group. Defaults
59
- * to `streaming` while any step is `running`. */
20
+ /** The agent message's ai-sdk `parts` the ordered transcript, rendered in
21
+ * place. Chat passes `message.parts`; an app passes `useAgentRun().parts`. */
22
+ parts: readonly AgentUIPart[];
23
+ /** Whole-run state. `streaming` keeps the tail activity group pulsing;
24
+ * `done`/`error` settle every group. Defaults to `streaming` while any tool
25
+ * step is running. */
60
26
  state?: "streaming" | "done" | "error";
27
+ /** A run-level BREAKING error — the message that terminated the run (a stream
28
+ * error, an API failure, a hit quota). It lives OUTSIDE `parts` (chat stores it
29
+ * in the message's `errors`; `useAgentRun().error` carries it), so pass it here;
30
+ * it renders as a terminal danger row under the transcript. A per-tool failure
31
+ * is different — it rides in that tool's own `output-error` part. */
32
+ error?: string;
61
33
  /** Localize / override a TOOL step's display label by its raw tool name
62
34
  * (`update_records` → "Đang cập nhật dữ liệu"). Return `undefined` to fall
63
35
  * back to the built-in label. */
@@ -107,36 +79,9 @@ export function resolveToolMeta(
107
79
  };
108
80
  }
109
81
 
110
- /** Resolve a step's display label a `tool` maps its raw name, a `step` is
111
- * already human. */
112
- function stepLabel(s: AgentRunStep, labelForTool?: (toolName: string) => string | undefined): string {
113
- return s.kind === "tool" ? resolveToolMeta(s.label, labelForTool).label : s.label;
114
- }
115
-
116
- // A run is a timeline of segments: prose, thinking, then a group of consecutive
117
- // tool calls, then more prose. Folding the flat item list into segments is what
118
- // lets the feed interleave instead of forcing all text to the top.
119
- type Segment =
120
- | { kind: "text"; id: string; text: string }
121
- | { kind: "reasoning"; id: string; text: string }
122
- | { kind: "group"; id: string; steps: AgentRunStep[] };
123
-
124
- function toSegments(items: AgentRunItem[]): Segment[] {
125
- const segments: Segment[] = [];
126
- for (const item of items) {
127
- if (item.type === "text" || item.type === "reasoning") {
128
- segments.push({ kind: item.type, id: item.id, text: item.text });
129
- continue;
130
- }
131
- const { type: _t, ...step } = item;
132
- const tail = segments[segments.length - 1];
133
- if (tail && tail.kind === "group") {
134
- segments[segments.length - 1] = { ...tail, steps: [...tail.steps, step] };
135
- } else {
136
- segments.push({ kind: "group", id: item.id, steps: [step] });
137
- }
138
- }
139
- return segments;
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;
140
85
  }
141
86
 
142
87
  const INK = colors.zinc[700];
@@ -147,15 +92,14 @@ const INK = colors.zinc[700];
147
92
  * tail group is a SINGLE pulsing row whose label swaps in place as each call
148
93
  * fires (not a growing stack of dots); once prose resumes the group settles into
149
94
  * one row — "{final action} · {n} steps" — that EXPANDS on press to the steps in
150
- * between. Text segments render as prose, the last one carrying a live caret. The
151
- * run always ends on the agent's text. Pair with `Composer` + `ChangeReview`.
95
+ * between. Text segments render as prose. The run always ends on the agent's text.
96
+ * Pair with `Composer` + `ChangeReview`.
152
97
  */
153
98
  export function AgentRun(props: AgentRunProps) {
154
- const { items, labelForTool, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;
155
- const anyRunning = items.some((it) => it.type === "step" && it.status === "running");
156
- const state = props.state ?? (anyRunning ? "streaming" : "done");
99
+ const { parts, labelForTool, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;
100
+ const segments = toSegments(parts);
101
+ const state = props.state ?? (anyRunning(segments) ? "streaming" : "done");
157
102
  const streaming = state === "streaming";
158
- const segments = toSegments(items);
159
103
  const lastIndex = segments.length - 1;
160
104
 
161
105
  const [expanded, setExpanded] = useState<Record<string, boolean>>({});
@@ -167,7 +111,7 @@ export function AgentRun(props: AgentRunProps) {
167
111
  if (seg.kind === "text") {
168
112
  return (
169
113
  <View key={seg.id} style={styles.narration}>
170
- <Markdown>{streaming && i === lastIndex ? `${seg.text} ▍` : seg.text}</Markdown>
114
+ <Markdown>{seg.text}</Markdown>
171
115
  </View>
172
116
  );
173
117
  }
@@ -186,6 +130,20 @@ export function AgentRun(props: AgentRunProps) {
186
130
  />
187
131
  );
188
132
  })}
133
+ {/* 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. */}
135
+ {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>
144
+ </View>
145
+ </View>
146
+ ) : null}
189
147
  </View>
190
148
  );
191
149
  }
@@ -213,7 +171,7 @@ function ActivityRow(props: {
213
171
  );
214
172
  if (onPress) {
215
173
  return (
216
- <PressableHighlight focusRing onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.row}>
174
+ <PressableHighlight focusRing userSelect="none" onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.row}>
217
175
  {inner}
218
176
  </PressableHighlight>
219
177
  );
@@ -221,25 +179,14 @@ function ActivityRow(props: {
221
179
  return <View style={styles.row}>{inner}</View>;
222
180
  }
223
181
 
224
- function StepBody({ label, detail, status }: { label: string; detail?: string; status: AgentStepStatus }) {
225
- return (
226
- <View style={{ gap: 1 }}>
227
- <Text size="sm">{label}</Text>
228
- {detail ? (
229
- <Text size="xs" color={status === "error" ? "danger" : "muted"}>
230
- {detail}
231
- </Text>
232
- ) : null}
233
- </View>
234
- );
182
+ function StepBody({ label }: { label: string }) {
183
+ return <Text size="sm">{label}</Text>;
235
184
  }
236
185
 
237
- // The press-to-open reveal for a tool step: the caller's `peek` wins; else Input /
238
- // Output / Error panels auto-built from the carried I/O; else nothing (no reveal).
239
- // Raw I/O stays OUT of the row — only shown here, on demand. Panel titles come from
240
- // the locale (the caller supplies a whole `peek` to override).
241
- function stepPeek(s: AgentRunStep, labels: LoticsLocale["agentRun"]): ReactNode {
242
- if (s.peek) return s.peek;
186
+ // The on-demand reveal for a tool step: Input / Output / Error panels auto-built
187
+ // 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 {
243
190
  const hasInput = s.input !== undefined;
244
191
  const hasOutput = s.output !== undefined;
245
192
  if (!hasInput && !hasOutput && !s.errorText) return null;
@@ -255,18 +202,28 @@ function stepPeek(s: AgentRunStep, labels: LoticsLocale["agentRun"]): ReactNode
255
202
  );
256
203
  }
257
204
 
258
- // A step's body, wrapped in a Peek when there's something to reveal (I/O or a
259
- // caller peek), plain otherwise.
260
- function StepContent({ s, label }: { s: AgentRunStep; label: string }) {
205
+ // A settled step row. When the step carries I/O (or an error) it EXPANDS IN PLACE on
206
+ // press — the panels roll out under the row (same disclosure pattern as "Thinking"),
207
+ // 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 }) {
261
209
  const locale = useLoticsLocale();
262
- const peek = stepPeek(s, locale.agentRun);
263
- const body = <StepBody label={label} detail={s.detail} status={s.status} />;
264
- return peek ? (
265
- <Peek accessibilityLabel={label} content={peek}>
266
- {body}
267
- </Peek>
268
- ) : (
269
- body
210
+ const [open, setOpen] = useState(false);
211
+ const detail = stepDetail(s, locale.agentRun);
212
+ const marker: StepStatus = s.status === "error" ? "warning" : "done";
213
+ if (!detail) {
214
+ return (
215
+ <ActivityRow markerStatus={marker}>
216
+ <StepBody label={label} />
217
+ </ActivityRow>
218
+ );
219
+ }
220
+ return (
221
+ <View>
222
+ <ActivityRow markerStatus={marker} onPress={() => setOpen((o) => !o)} accessibilityLabel={label} trailing={chevron(open ? "up" : "down")}>
223
+ <StepBody label={label} />
224
+ </ActivityRow>
225
+ {open ? <View style={styles.rowDetail}>{detail}</View> : null}
226
+ </View>
270
227
  );
271
228
  }
272
229
 
@@ -277,7 +234,7 @@ function ReasoningDisclosure(props: { text: string; streaming?: boolean; expande
277
234
  const locale = useLoticsLocale();
278
235
  return (
279
236
  <View style={styles.group}>
280
- <PressableHighlight focusRing onPress={onToggle} accessibilityRole="button" accessibilityLabel={locale.agentRun.thinking} style={styles.row}>
237
+ <PressableHighlight focusRing userSelect="none" onPress={onToggle} accessibilityRole="button" accessibilityLabel={locale.agentRun.thinking} style={styles.row}>
281
238
  <View style={styles.dotCol}>
282
239
  <Marker status={streaming ? "current" : "done"} color={colors.zinc[400]} live={!!streaming} />
283
240
  </View>
@@ -313,7 +270,7 @@ function chevron(dir: "down" | "up") {
313
270
  }
314
271
 
315
272
  function ToolGroup(props: {
316
- steps: AgentRunStep[];
273
+ steps: AgentStep[];
317
274
  active: boolean;
318
275
  expanded: boolean;
319
276
  onToggle: () => void;
@@ -321,7 +278,7 @@ function ToolGroup(props: {
321
278
  stepsLabel: (n: number) => string;
322
279
  }) {
323
280
  const { steps, active, expanded, onToggle, labelForTool, stepsLabel } = props;
324
- const resolve = (s: AgentRunStep) => stepLabel(s, labelForTool);
281
+ const resolve = (s: AgentStep) => stepLabel(s, labelForTool);
325
282
 
326
283
  // ACTIVE — one pulsing row whose label swaps in place as each call fires (the
327
284
  // label is keyed by the current step's id, so a new call rises + fades into the
@@ -332,7 +289,7 @@ function ToolGroup(props: {
332
289
  <View style={styles.group}>
333
290
  <ActivityRow markerStatus="current" live>
334
291
  <AnimationFadeIn key={current.id} translateY={4}>
335
- <StepBody label={resolve(current)} detail={current.detail} status={current.status} />
292
+ <StepBody label={resolve(current)} />
336
293
  </AnimationFadeIn>
337
294
  </ActivityRow>
338
295
  </View>
@@ -343,13 +300,11 @@ function ToolGroup(props: {
343
300
  const final = steps[steps.length - 1];
344
301
  const expandable = steps.length > 1;
345
302
 
346
- // SETTLED, single call — just the one done/warning row (nothing to expand).
303
+ // SETTLED, single call — one done/warning row (its I/O expands in place on press).
347
304
  if (!expandable) {
348
305
  return (
349
306
  <View style={styles.group}>
350
- <ActivityRow markerStatus={final.status === "error" ? "warning" : "done"}>
351
- <StepContent s={final} label={resolve(final)} />
352
- </ActivityRow>
307
+ <StepRow s={final} label={resolve(final)} />
353
308
  </View>
354
309
  );
355
310
  }
@@ -372,9 +327,7 @@ function ToolGroup(props: {
372
327
  {expanded
373
328
  ? steps.map((s) => (
374
329
  <AnimationFadeIn key={s.id} translateY={4}>
375
- <ActivityRow markerStatus={s.status === "error" ? "warning" : "done"}>
376
- <StepContent s={s} label={resolve(s)} />
377
- </ActivityRow>
330
+ <StepRow s={s} label={resolve(s)} />
378
331
  </AnimationFadeIn>
379
332
  ))
380
333
  : null}
@@ -394,9 +347,17 @@ const styles = StyleSheet.create({
394
347
  gap: 10,
395
348
  minHeight: CONTROL_HEIGHT,
396
349
  borderRadius: CONTROL_RADIUS,
350
+ // The hover/press highlight BLEEDS 8px past the content on each side (negative
351
+ // margin absorbed by equal padding, the Peek pattern), so its rounded edges
352
+ // never pinch against the leading dot or the trailing chevron. Content stays
353
+ // flush-aligned (net zero), so the run's left edge still meets the consumer's gutter.
354
+ marginHorizontal: -8,
355
+ paddingHorizontal: 8,
397
356
  },
398
357
  dotCol: { width: 18, alignItems: "center" },
399
358
  rowBody: { flex: 1 },
400
359
  // Reasoning body aligns under the "Thinking" label (past the dot column + gap).
401
360
  reasoning: { paddingLeft: 28, paddingBottom: 4 },
361
+ // A step's expanded I/O panels — same left edge as the reasoning body.
362
+ rowDetail: { paddingLeft: 28, paddingBottom: 6, paddingTop: 2 },
402
363
  });
@@ -0,0 +1,102 @@
1
+ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
2
+
3
+ // `@lotics/ui` renders an agent run straight from the ai-sdk `UIMessage.parts` — the
4
+ // canonical, versioned wire shape BOTH the chat and app agents already emit. There
5
+ // is no bespoke transcript type: this module is the one place that folds parts into
6
+ // the render timeline, shared by `AgentRun` and `AgentProgress`. Consumers pass their
7
+ // message's `parts` (chat's static `tool-<name>` parts and the app SDK's
8
+ // `dynamic-tool` parts are both handled).
9
+
10
+ /** An ai-sdk message part, tool-set-agnostic (we only read `type` + a few fields). */
11
+ export type AgentUIPart = UIMessagePart<UIDataTypes, UITools>;
12
+
13
+ /** A tool step's settle state, collapsed from ai's 7-state tool machine. */
14
+ export type AgentStepStatus = "running" | "done" | "error";
15
+
16
+ /** One tool call, reduced to what the feed shows. `toolName` is the RAW name — the
17
+ * renderer maps it to a human label + icon (`resolveToolMeta`). */
18
+ export interface AgentStep {
19
+ id: string;
20
+ toolName: string;
21
+ status: AgentStepStatus;
22
+ /** Revealed ON DEMAND (a press-to-open peek), never inline. */
23
+ input?: unknown;
24
+ output?: unknown;
25
+ errorText?: string;
26
+ }
27
+
28
+ /** The render timeline: prose, thinking, and groups of consecutive tool calls, in
29
+ * the order they streamed. */
30
+ export type AgentSegment =
31
+ | { kind: "text"; id: string; text: string }
32
+ | { kind: "reasoning"; id: string; text: string }
33
+ | { kind: "group"; id: string; steps: AgentStep[] };
34
+
35
+ // A tool part — static (`tool-<name>`) or `dynamic-tool` — is exactly the part union
36
+ // that carries `toolCallId`, so extracting on that field is a sound, cast-free guard.
37
+ function isToolPart(part: AgentUIPart): part is Extract<AgentUIPart, { toolCallId: string }> {
38
+ return part.type === "dynamic-tool" || part.type.startsWith("tool-");
39
+ }
40
+
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.
43
+ function toStatus(state: string): AgentStepStatus {
44
+ if (state === "output-available") return "done";
45
+ if (state === "output-error" || state === "output-denied") return "error";
46
+ return "running";
47
+ }
48
+
49
+ function toStep(part: Extract<AgentUIPart, { toolCallId: string }>): AgentStep {
50
+ return {
51
+ id: part.toolCallId,
52
+ toolName: part.type === "dynamic-tool" ? part.toolName : part.type.slice("tool-".length),
53
+ status: toStatus(part.state),
54
+ input: part.input,
55
+ output: part.state === "output-available" ? part.output : undefined,
56
+ errorText: part.state === "output-error" ? part.errorText : undefined,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Fold a message's parts into the render timeline. Consecutive tool parts collapse
62
+ * into ONE group (the feed shows a burst as a single expandable row). Prose parts
63
+ * carry no id in ai's model, so a stable index-based one is synthesized. Part kinds
64
+ * with no feed representation (`step-start`, `file`, `source-*`, `data-*`, `custom`)
65
+ * are skipped — the same kinds the chat's own renderer ignores.
66
+ */
67
+ export function toSegments(parts: readonly AgentUIPart[]): AgentSegment[] {
68
+ const segments: AgentSegment[] = [];
69
+ parts.forEach((part, i) => {
70
+ if (part.type === "text") {
71
+ if (part.text) segments.push({ kind: "text", id: `text-${i}`, text: part.text });
72
+ return;
73
+ }
74
+ if (part.type === "reasoning") {
75
+ if (part.text) segments.push({ kind: "reasoning", id: `reasoning-${i}`, text: part.text });
76
+ return;
77
+ }
78
+ if (!isToolPart(part)) return;
79
+ const step = toStep(part);
80
+ const tail = segments[segments.length - 1];
81
+ if (tail?.kind === "group") tail.steps.push(step);
82
+ else segments.push({ kind: "group", id: step.id, steps: [step] });
83
+ });
84
+ return segments;
85
+ }
86
+
87
+ /** Whether any tool step is still running — the default "is the run streaming" signal. */
88
+ export function anyRunning(segments: readonly AgentSegment[]): boolean {
89
+ return segments.some((s) => s.kind === "group" && s.steps.some((st) => st.status === "running"));
90
+ }
91
+
92
+ /** The last still-running tool step, if any — for a compact "currently doing X". */
93
+ export function lastRunningStep(segments: readonly AgentSegment[]): AgentStep | undefined {
94
+ for (let i = segments.length - 1; i >= 0; i--) {
95
+ const seg = segments[i];
96
+ if (seg.kind !== "group") continue;
97
+ for (let j = seg.steps.length - 1; j >= 0; j--) {
98
+ if (seg.steps[j].status === "running") return seg.steps[j];
99
+ }
100
+ }
101
+ return undefined;
102
+ }
@@ -1,10 +1,22 @@
1
- import { Pressable, View } from "react-native";
1
+ import { useState } from "react";
2
+ import { Pressable, TextInput, View } from "react-native";
2
3
  import { colors } from "./colors";
3
4
  import { Text } from "./text";
4
5
  import { Icon } from "./icon";
5
6
  import { Divider } from "./divider";
6
- import { useFocusRing } from "./use_focus_ring";
7
- import { FOCUS_RING } from "./control_surface";
7
+ import { composeHandler, useFocusRing } from "./use_focus_ring";
8
+ import { useHover } from "./use_hover";
9
+ import { CONTROL_RADIUS, FOCUS_RING } from "./control_surface";
10
+ import { useAutoGrowHeight } from "./use_auto_grow_height";
11
+ import { fontFamilyMedium, getInputTextStyle } from "./text_utils";
12
+ import { useLoticsLocale } from "./locale";
13
+
14
+ // A typed custom answer must read exactly like a picked option's LABEL (`Text
15
+ // size="sm" weight="medium"`). Its SIZE stays on the input contract (getInputTextStyle:
16
+ // 14 on desktop = the sm label, 16 on mobile to defeat iOS-Safari focus-zoom — and the
17
+ // size useAutoGrowHeight is tuned to); this override only aligns the family, tracking,
18
+ // and colour to the label, which is where an unstyled input actually diverges from it.
19
+ const ANSWER_LABEL_FONT = { fontFamily: fontFamilyMedium, letterSpacing: -0.4, color: colors.zinc[900] } as const;
8
20
 
9
21
  export interface ChoiceOption {
10
22
  label: string;
@@ -15,25 +27,33 @@ export interface ChoiceOption {
15
27
 
16
28
  export interface ChoiceListProps {
17
29
  options: ChoiceOption[];
18
- /** The chosen value. */
30
+ /** The chosen value — an option's `value`, or (with `allowCustom`) the free
31
+ * TEXT of an "Other" answer. `undefined` = nothing chosen. */
19
32
  value?: string;
20
33
  onSelect: (value: string) => void;
34
+ /** Show an always-visible inline free-text field at the bottom for a custom
35
+ * answer — typing it IS the selection. Its text IS the value (any `value` not
36
+ * matching an option reads as the custom answer, so the caller distinguishes a
37
+ * custom answer by "not in `options`"). */
38
+ allowCustom?: boolean;
21
39
  }
22
40
 
23
41
  /** One selectable option — a role-less-free `Pressable` (its own accessible name
24
42
  * + selected state), hover wash + focus ring, no border. */
25
- function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selected: boolean; onSelect: (value: string) => void }) {
43
+ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selected: boolean; onSelect: () => void }) {
26
44
  const { focusVisible, focusProps } = useFocusRing();
27
45
  return (
28
46
  <Pressable
29
47
  accessibilityRole="button"
30
48
  accessibilityLabel={option.label}
31
49
  accessibilityState={{ selected }}
32
- onPress={() => onSelect(option.value)}
50
+ onPress={onSelect}
33
51
  {...focusProps}
34
52
  style={({ hovered, pressed }) => [
35
- { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: 6, borderRadius: 8 },
36
- hovered || pressed ? { backgroundColor: colors.zinc[50] } : null,
53
+ { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS },
54
+ // Selected reads as a persistent tint (zinc-100); an unselected row only
55
+ // washes on hover (zinc-50). The check below reserves its slot always.
56
+ selected ? { backgroundColor: colors.zinc[100] } : hovered || pressed ? { backgroundColor: colors.zinc[50] } : null,
37
57
  focusVisible ? { boxShadow: FOCUS_RING } : null,
38
58
  ]}
39
59
  >
@@ -47,28 +67,97 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
47
67
  </Text>
48
68
  ) : null}
49
69
  </View>
50
- {selected ? <Icon name="check" size={17} color={colors.zinc[900]} /> : null}
70
+ <View style={{ width: 17, alignItems: "center" }}>
71
+ {selected ? <Icon name="check" size={17} color={colors.zinc[900]} /> : null}
72
+ </View>
51
73
  </Pressable>
52
74
  );
53
75
  }
54
76
 
77
+ /** The custom-answer row — the SAME box as a `ChoiceRow` (padding · radius · hover
78
+ * wash · focus ring), but holding an always-editable, auto-growing multiline input
79
+ * instead of a pressable label. Typing it IS the selection; focusing it re-selects a
80
+ * parked draft (`onFocus`). */
81
+ function CustomAnswerRow({ value, selected, onChangeText, onFocus, placeholder }: { value: string; selected: boolean; onChangeText: (text: string) => void; onFocus: () => void; placeholder: string }) {
82
+ const { hovered, hoverProps } = useHover();
83
+ const { focusVisible, focusProps } = useFocusRing({ always: true });
84
+ const grow = useAutoGrowHeight({ minLines: 1 });
85
+ return (
86
+ <View
87
+ {...(hoverProps as object)}
88
+ style={[
89
+ { paddingVertical: 14, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS },
90
+ // Same states as an option row: a filled custom answer reads as selected
91
+ // (zinc-100); otherwise it washes on hover (zinc-50).
92
+ selected ? { backgroundColor: colors.zinc[100] } : hovered ? { backgroundColor: colors.zinc[50] } : null,
93
+ focusVisible ? { boxShadow: FOCUS_RING } : null,
94
+ ]}
95
+ >
96
+ <TextInput
97
+ ref={grow.inputRef}
98
+ multiline
99
+ value={value}
100
+ onChangeText={(text) => { onChangeText(text); grow.measure(); }}
101
+ onFocus={composeHandler(onFocus, focusProps.onFocus)}
102
+ onBlur={focusProps.onBlur}
103
+ onContentSizeChange={grow.onContentSizeChange}
104
+ scrollEnabled={grow.scrollEnabled}
105
+ placeholder={placeholder}
106
+ placeholderTextColor={colors.zinc[400]}
107
+ // Bare input: the row owns padding + hover + focus ring, so it strips its own
108
+ // padding and browser outline and just grows with the text. Input-contract size +
109
+ // the option-label family/tracking/colour, so a typed answer reads like a picked one.
110
+ style={[getInputTextStyle(), ANSWER_LABEL_FONT, { height: grow.containerHeight, padding: 0, outlineStyle: "none" as unknown as "solid" }]}
111
+ />
112
+ </View>
113
+ );
114
+ }
115
+
55
116
  /**
56
117
  * A vertical set of selectable answer options — divider-separated rows (no
57
118
  * bordered cards), each with its own focus ring + hover wash, single-select and
58
119
  * freely switchable (pick a different option any time). The chosen row shows a
59
- * check. The agent's quick-reply surface (`Clarify` uses it) and any "pick one"
60
- * question.
120
+ * check. With `allowCustom`, an always-visible inline free-text field at the
121
+ * bottom captures a custom answer (typing it IS the selection). The agent's
122
+ * quick-reply surface (`Clarify` / `ClarifyWizard` use it) and any "pick one" question.
61
123
  */
62
124
  export function ChoiceList(props: ChoiceListProps) {
63
- const { options, value, onSelect } = props;
125
+ const { options, value, onSelect, allowCustom } = props;
126
+ const labels = useLoticsLocale().clarify;
127
+ // A value that matches no option IS a custom answer (its own text, or "" right
128
+ // after focusing an empty custom row).
129
+ const isCustomValue = (v: string | undefined): v is string => !!allowCustom && typeof v === "string" && !options.some((o) => o.value === v);
130
+ // The custom TEXT is a draft that outlives selection: picking an option deselects
131
+ // the custom row but must KEEP its text so the human can return to it. The single
132
+ // `value` can't hold both a picked option and the parked text, so the draft is
133
+ // local — seeded from `value` when this row mounts already custom-selected.
134
+ const [customDraft, setCustomDraft] = useState(() => (isCustomValue(value) ? value : ""));
135
+ const customSelected = isCustomValue(value);
64
136
  return (
65
137
  <View>
66
138
  {options.map((o, i) => (
67
139
  <View key={o.value}>
68
- {i > 0 ? <Divider /> : null}
69
- <ChoiceRow option={o} selected={value === o.value} onSelect={onSelect} />
140
+ {/* The hairline carries vertical padding so each row's rounded hover/selected
141
+ fill has air on both sides of it (a flush divider would pinch the corners). */}
142
+ {i > 0 ? <Divider paddingVertical={6} /> : null}
143
+ <ChoiceRow option={o} selected={value === o.value} onSelect={() => onSelect(o.value)} />
70
144
  </View>
71
145
  ))}
146
+ {allowCustom ? (
147
+ <View>
148
+ {options.length > 0 ? <Divider paddingVertical={6} /> : null}
149
+ {/* The custom answer is an ALWAYS-visible inline row (no press-to-reveal). Its
150
+ text persists (customDraft) across option switches; typing selects it, and
151
+ focusing a non-empty parked draft re-selects it without retyping. */}
152
+ <CustomAnswerRow
153
+ value={customDraft}
154
+ selected={customSelected && customDraft.length > 0}
155
+ onChangeText={(text) => { setCustomDraft(text); onSelect(text); }}
156
+ onFocus={() => { if (customDraft.length > 0 && !customSelected) onSelect(customDraft); }}
157
+ placeholder={labels.otherPlaceholder}
158
+ />
159
+ </View>
160
+ ) : null}
72
161
  </View>
73
162
  );
74
163
  }