@lotics/ui 5.3.0 → 5.5.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
@@ -1,174 +1,169 @@
1
- import { StyleSheet, View } from "react-native";
2
- import { colors, solid, tint } from "./colors";
1
+ import { useEffect, useRef, type ReactNode } from "react";
2
+ import { Animated, StyleSheet, View } from "react-native";
3
+ import { colors, solid } from "./colors";
3
4
  import { Text } from "./text";
4
- import { Icon, type IconName } from "./icon";
5
- import { ActivityIndicator } from "./activity_indicator";
6
- import { AnimationFadeIn } from "./animation_fade_in";
5
+ import { type IconName } from "./icon";
6
+ import { Peek } from "./peek";
7
+ import { Stepper, Step, type StepStatus } from "./stepper";
7
8
 
8
9
  export type AgentStepStatus = "running" | "done" | "error";
9
10
 
10
11
  export interface AgentRunStep {
11
12
  id: string;
12
- /** The action line what the agent is doing ("Reading the image",
13
- * "Identifying the box style"). */
13
+ /** A `tool` step carries the RAW tool name (`update_records`) the feed maps
14
+ * it to a human label via the default map / `labelForTool`. A `step` carries
15
+ * an already-human label. */
14
16
  label: string;
15
- /** Streamed reasoning or result text under the label (muted). Grows as the
16
- * model streams; the host appends to it. */
17
+ /** A note under the label (a count, an input summary), muted. */
17
18
  detail?: string;
18
19
  status: AgentStepStatus;
19
- /** A `tool` step renders its label in a monospace chip — it called a tool
20
- * rather than reasoned. */
21
20
  kind?: "step" | "tool";
21
+ /** Optional content shown in a `Peek` popover when the step is pressed —
22
+ * glance into what the step did (the tool input, a result summary). */
23
+ peek?: ReactNode;
22
24
  }
23
25
 
24
26
  export interface AgentRunProps {
25
27
  steps: AgentRunStep[];
26
- /** Whole-run state. `streaming` keeps the feed alivethe running node spins
27
- * and the header shows a working indicator; `done`/`error` settle it.
28
- * Defaults to `streaming` while any step is running, else `done`. */
28
+ /** The agent's streamed reasoning / answer textshown as a narration block
29
+ * above the feed, with a live caret while streaming. */
30
+ text?: string;
31
+ /** Whole-run state. `streaming` keeps the caret blinking + the latest step
32
+ * pulsing; `done`/`error` settle it. Defaults to `streaming` while a step runs. */
29
33
  state?: "streaming" | "done" | "error";
30
- /** Header label the task the agent is performing ("Designing the dieline").
31
- * Omit for a bare step feed with no header band. */
32
- title?: string;
34
+ /** Localize / override a TOOL step's display label by its raw tool name
35
+ * (`update_records` "Đang cập nhật dữ liệu"). Return `undefined` to fall
36
+ * back to the built-in label. */
37
+ labelForTool?: (toolName: string) => string | undefined;
38
+ /** The always-last terminal step's label. Default "Done"; localize per app. */
39
+ doneLabel?: string;
33
40
  accessibilityLabel?: string;
34
41
  }
35
42
 
43
+ // ── Tool → display meta ───────────────────────────────────────────────────────
44
+ // 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
46
+ // doesn't know falls back to a prettified name + a neutral icon.
47
+ const TOOL_META: Record<string, { label: string; icon: IconName }> = {
48
+ query_records: { label: "Searching records", icon: "search" },
49
+ get_record: { label: "Reading a record", icon: "file-text" },
50
+ get_records: { label: "Reading records", icon: "file-text" },
51
+ create_records: { label: "Creating records", icon: "plus" },
52
+ update_records: { label: "Updating records", icon: "square-pen" },
53
+ delete_records: { label: "Removing records", icon: "trash" },
54
+ generate_pdf_from_template: { label: "Generating PDF", icon: "file-down" },
55
+ generate_excel_from_template: { label: "Generating spreadsheet", icon: "file-spreadsheet" },
56
+ generate_docx_from_template: { label: "Generating document", icon: "file-text" },
57
+ };
58
+
59
+ function prettifyToolName(name: string): string {
60
+ const s = name.replace(/_/g, " ").trim();
61
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : name;
62
+ }
63
+
64
+ /** Resolve a tool's display label + icon: a caller override wins on the label,
65
+ * then the built-in map, then a prettified fallback. Exported so other agent
66
+ * surfaces (e.g. `AgentProgress`) render the same labels. */
67
+ export function resolveToolMeta(
68
+ toolName: string,
69
+ labelForTool?: (toolName: string) => string | undefined,
70
+ ): { label: string; icon: IconName } {
71
+ const def =
72
+ TOOL_META[toolName] ??
73
+ (toolName.startsWith("generate_") && toolName.endsWith("_from_template")
74
+ ? { label: "Generating document", icon: "file-text" as IconName }
75
+ : undefined);
76
+ return {
77
+ label: labelForTool?.(toolName) ?? def?.label ?? prettifyToolName(toolName),
78
+ icon: def?.icon ?? "list",
79
+ };
80
+ }
81
+
36
82
  /**
37
- * A live feed of an AI agent's work — the steps it takes (reasoning, tool
38
- * calls, results) revealed as they happen, on a connecting spine. The
39
- * transparent-work surface, NOT a spinner. Shares the `Timeline` node anatomy: a
40
- * tinted disc behind a full-colour icon, fading in as each step arrives. A
41
- * running step spins (blue); a finished step settles to an emerald check; a
42
- * failure to a red alert. Pair with `Composer` (the command that starts a
43
- * run) and `ReviewCard` (the result to review). Unlike `StepList` (a known,
44
- * guided run of fixed steps) the steps here arrive unknown-ahead.
83
+ * A live feed of an AI agent's work — the prose it streams + the tools it calls,
84
+ * on a vertical `Stepper`: one neutral dot per call (the latest pulsing while the
85
+ * run is live, a check once done) with a plain-text label, no chip/icon, ALWAYS
86
+ * ending on a "Done" terminal step. A step can carry `peek` content to glance
87
+ * into via a popover. Tool names map to labels via the built-in default + a
88
+ * `labelForTool` override. Pair with `Composer` + `ReviewCard`.
45
89
  */
46
90
  export function AgentRun(props: AgentRunProps) {
47
- const { steps, title, accessibilityLabel } = props;
91
+ const { steps, text, labelForTool, doneLabel = "Done", accessibilityLabel } = props;
48
92
  const anyRunning = steps.some((s) => s.status === "running");
49
93
  const state = props.state ?? (anyRunning ? "streaming" : "done");
50
94
  const streaming = state === "streaming";
95
+ const last = steps.length - 1;
51
96
 
52
97
  return (
53
- <View accessibilityLabel={accessibilityLabel} style={{ gap: title ? 12 : 0 }}>
54
- {title ? (
55
- <View style={styles.header}>
56
- <Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={1}>
57
- {title}
58
- </Text>
59
- <HeaderStatus state={state} streaming={streaming} />
60
- </View>
98
+ <View accessibilityLabel={accessibilityLabel} style={{ gap: 12 }}>
99
+ {text ? (
100
+ <Text size="sm" style={styles.narration}>
101
+ {text}
102
+ {streaming ? <Caret /> : null}
103
+ </Text>
61
104
  ) : null}
62
105
 
63
- <View>
106
+ <Stepper orientation="vertical" live={streaming}>
64
107
  {steps.map((s, i) => {
65
- const last = i === steps.length - 1;
66
- return (
67
- <View key={s.id} style={styles.item}>
68
- <View style={styles.spineCol}>
69
- <AnimationFadeIn key={`${s.id}-${s.status}`}>
70
- <StepNode status={s.status} />
71
- </AnimationFadeIn>
72
- {!last ? <View style={styles.spine} /> : null}
73
- </View>
74
- <View style={[styles.contentCol, !last ? styles.contentGap : null]}>
75
- {s.kind === "tool" ? (
76
- <View style={styles.toolChip}>
77
- <Text size="xs" color="muted" numberOfLines={1}>
78
- {s.label}
79
- </Text>
80
- </View>
81
- ) : (
82
- <Text
83
- size="sm"
84
- weight={s.status === "running" ? "medium" : "regular"}
85
- color={s.status === "done" ? "muted" : s.status === "error" ? "danger" : "default"}
86
- >
87
- {s.label}
88
- </Text>
89
- )}
90
- {s.detail ? (
91
- <Text size="xs" color="muted" style={{ marginTop: 2 }}>
92
- {s.detail}
93
- </Text>
94
- ) : null}
95
- </View>
108
+ // Tools the SDK feeds arrive already "done"; show the most recent as
109
+ // the pulsing current while the run is still live.
110
+ const status: StepStatus =
111
+ s.status === "error"
112
+ ? "warning"
113
+ : s.status === "running" || (streaming && i === last)
114
+ ? "current"
115
+ : "done";
116
+ const label = s.kind === "tool" ? resolveToolMeta(s.label, labelForTool).label : s.label;
117
+ const body = (
118
+ <View style={{ gap: 1 }}>
119
+ <Text size="sm">{label}</Text>
120
+ {s.detail ? (
121
+ <Text size="xs" color={s.status === "error" ? "danger" : "muted"}>
122
+ {s.detail}
123
+ </Text>
124
+ ) : null}
96
125
  </View>
97
126
  );
127
+ return (
128
+ <Step key={s.id} status={status}>
129
+ {s.peek ? (
130
+ <Peek accessibilityLabel={label} content={s.peek}>
131
+ {body}
132
+ </Peek>
133
+ ) : (
134
+ body
135
+ )}
136
+ </Step>
137
+ );
98
138
  })}
99
- </View>
139
+ {state === "done" ? (
140
+ <Step key="__done" status="complete">
141
+ <Text size="sm" weight="medium">
142
+ {doneLabel}
143
+ </Text>
144
+ </Step>
145
+ ) : null}
146
+ </Stepper>
100
147
  </View>
101
148
  );
102
149
  }
103
150
 
104
- const NODE_STATUS: Record<"done" | "error", { color: string; icon: IconName }> = {
105
- done: { color: solid("emerald"), icon: "check" },
106
- error: { color: solid("red"), icon: "circle-alert" },
107
- };
108
-
109
- function StepNode({ status }: { status: AgentStepStatus }) {
110
- if (status === "running") {
111
- return (
112
- <View style={[styles.node, { backgroundColor: tint("blue", 0.12) }]}>
113
- <ActivityIndicator size={12} color={solid("blue")} />
114
- </View>
151
+ // A blinking caret that trails the streamed text the signature of live writing.
152
+ function Caret() {
153
+ const opacity = useRef(new Animated.Value(1)).current;
154
+ useEffect(() => {
155
+ const loop = Animated.loop(
156
+ Animated.sequence([
157
+ Animated.timing(opacity, { toValue: 0, duration: 480, useNativeDriver: false }),
158
+ Animated.timing(opacity, { toValue: 1, duration: 480, useNativeDriver: false }),
159
+ ]),
115
160
  );
116
- }
117
- const s = NODE_STATUS[status];
118
- return (
119
- <View style={[styles.node, { backgroundColor: tint(status === "done" ? "emerald" : "red", 0.12) }]}>
120
- <Icon name={s.icon} size={13} color={s.color} />
121
- </View>
122
- );
123
- }
124
-
125
- function HeaderStatus({ state, streaming }: { state: "streaming" | "done" | "error"; streaming: boolean }) {
126
- if (streaming) {
127
- return (
128
- <View style={styles.headerStatus}>
129
- <ActivityIndicator size={12} color={solid("blue")} />
130
- <Text size="xs" weight="medium" style={{ color: solid("blue") }}>
131
- Working
132
- </Text>
133
- </View>
134
- );
135
- }
136
- if (state === "error") {
137
- return (
138
- <View style={styles.headerStatus}>
139
- <Icon name="circle-alert" size={13} color={solid("red")} />
140
- <Text size="xs" weight="medium" color="danger">
141
- Stopped
142
- </Text>
143
- </View>
144
- );
145
- }
146
- return (
147
- <View style={styles.headerStatus}>
148
- <Icon name="circle-check" size={13} color={solid("emerald")} />
149
- <Text size="xs" weight="medium" style={{ color: solid("emerald") }}>
150
- Done
151
- </Text>
152
- </View>
153
- );
161
+ loop.start();
162
+ return () => loop.stop();
163
+ }, [opacity]);
164
+ return <Animated.Text style={{ opacity, color: solid("blue") }}>▍</Animated.Text>;
154
165
  }
155
166
 
156
- const NODE = 22;
157
-
158
167
  const styles = StyleSheet.create({
159
- header: { flexDirection: "row", alignItems: "center", gap: 8, minHeight: 20 },
160
- headerStatus: { flexDirection: "row", alignItems: "center", gap: 5 },
161
- item: { flexDirection: "row", gap: 12 },
162
- spineCol: { width: NODE, alignItems: "center", paddingTop: 1 },
163
- spine: { width: 1.5, flex: 1, minHeight: 12, borderRadius: 1, backgroundColor: colors.zinc[200], marginTop: 3 },
164
- contentCol: { flex: 1, paddingTop: 2 },
165
- contentGap: { paddingBottom: 14 },
166
- node: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
167
- toolChip: {
168
- alignSelf: "flex-start",
169
- paddingHorizontal: 8,
170
- paddingVertical: 3,
171
- borderRadius: 6,
172
- backgroundColor: colors.zinc[100],
173
- },
168
+ narration: { lineHeight: 21, color: colors.zinc[700] },
174
169
  });
@@ -4,19 +4,26 @@ import { Animated, StyleProp, ViewStyle } from "react-native";
4
4
  interface AnimationFadeInProps {
5
5
  children: React.ReactNode;
6
6
  style?: StyleProp<ViewStyle>;
7
+ /** Rise this many px while fading in — the "appearing into place" gesture.
8
+ * Default 0 (fade only). */
9
+ translateY?: number;
7
10
  }
8
11
 
9
12
  export function AnimationFadeIn(props: AnimationFadeInProps) {
10
- const { children, style } = props;
11
- const opacity = useRef(new Animated.Value(0)).current;
13
+ const { children, style, translateY = 0 } = props;
14
+ const progress = useRef(new Animated.Value(0)).current;
12
15
 
13
16
  useEffect(() => {
14
- Animated.spring(opacity, {
17
+ Animated.spring(progress, {
15
18
  toValue: 1,
16
19
  bounciness: 0,
17
20
  useNativeDriver: true,
18
21
  }).start();
19
- }, [opacity]);
22
+ }, [progress]);
20
23
 
21
- return <Animated.View style={[{ opacity }, style]}>{children}</Animated.View>;
24
+ const transform = translateY
25
+ ? [{ translateY: progress.interpolate({ inputRange: [0, 1], outputRange: [translateY, 0] }) }]
26
+ : undefined;
27
+
28
+ return <Animated.View style={[{ opacity: progress, transform }, style]}>{children}</Animated.View>;
22
29
  }
@@ -32,22 +32,45 @@ export interface ChangeReviewProps<T> {
32
32
  summary?: string;
33
33
  /** Card heading. Default "Suggested edits". */
34
34
  title?: string;
35
- /** Providing either flips the card into 1-BY-1 mode: each proposal is its own
36
- * card with Keep / Drop; deciding COLLAPSES it to a preview the host can
37
- * `onUndoItem`. "Accept all" + "Apply kept" sit in a separate footer. The
38
- * decision is recorded nothing writes until Apply. Omit both for whole-set. */
35
+ /** Providing either flips into 1-BY-1 mode: each proposal is its own card with
36
+ * Keep / Drop; deciding COLLAPSES it to a preview the host can `onUndoItem`.
37
+ * Omit both for whole-set (the list, reviewed together). The commit bar
38
+ * (Accept all / Apply / Discard) is NOT here it's `ChangeReviewActions`,
39
+ * which the host places in a `DialogFooter` / `DrawerFooter` / inline. */
39
40
  onAcceptItem?: (index: number) => void;
40
41
  onRejectItem?: (index: number) => void;
41
42
  /** Revert a decided item back to pending (the Undo on a collapsed card). */
42
43
  onUndoItem?: (index: number) => void;
43
- onApply?: () => void;
44
- onDiscard?: () => void;
45
- applyLabel?: string;
46
44
  /** 1-by-1 per-item action labels. Default "Keep" / "Drop". */
47
45
  acceptLabel?: string;
48
46
  rejectLabel?: string;
47
+ /** Once resolved the list settles to a quiet outcome line. */
48
+ status?: "open" | "applied" | "discarded";
49
+ /** Localize the chrome the LIST emits itself (Keep/Drop take their own props;
50
+ * the commit-bar labels live on `ChangeReviewActions`). */
51
+ labels?: {
52
+ undo?: string;
53
+ applied?: string;
54
+ discarded?: string;
55
+ /** The "N of M kept" counter — default `${kept} of ${total} kept`. */
56
+ keptCount?: (kept: number, total: number) => string;
57
+ };
58
+ }
59
+
60
+ export interface ChangeReviewActionsProps<T> {
61
+ /** The same items + per-item status the list reads, so Accept-all knows which
62
+ * are pending and Apply knows the kept count. */
63
+ items: T[];
64
+ statusOf?: (item: T, index: number) => ChangeReviewItemStatus;
65
+ /** Presence enables 1-by-1: shows Accept-all and disables Apply at 0 kept. Omit
66
+ * for whole-set (no Accept-all; Apply commits the lot). */
67
+ onAcceptItem?: (index: number) => void;
68
+ onApply?: () => void;
69
+ onDiscard?: () => void;
70
+ applyLabel?: string;
49
71
  acceptAllLabel?: string;
50
- /** Once resolved the card settles to a quiet outcome line (no actions). */
72
+ discardLabel?: string;
73
+ /** Self-hides unless open — the host can always render it. */
51
74
  status?: "open" | "applied" | "discarded";
52
75
  }
53
76
 
@@ -82,13 +105,15 @@ export function ChangeDiff({ before, after }: { before?: string; after: string }
82
105
 
83
106
  /**
84
107
  * An agent-proposed set of changes, reviewed before it lands — never
85
- * auto-applied. The component owns the review MECHANICS; `renderItem` owns how
86
- * each proposal looks, so it serves a field diff, a record, or an editable form
87
- * equally. Two modes. Whole-set: the list + apply / discard the lot. 1-BY-1
88
- * (when the host passes `onAcceptItem`/`onRejectItem`): each proposal is its own
89
- * card with Keep / Drop; deciding COLLAPSES the card to a preview with Undo, and
90
- * a separate footer carries Accept-all + the single Apply that commits the kept
91
- * set. Where a `ReviewCard` reviews a SINGLE proposal, this is the BATCH engine.
108
+ * auto-applied. This is the LIST half: it owns the review MECHANICS (per-item
109
+ * decide + collapse, the kept counter), `renderItem` owns how each proposal
110
+ * looks. The COMMIT BAR is `ChangeReviewActions` a separate piece the host
111
+ * places where commit bars belong (a `DialogFooter`, a `DrawerFooter`, or
112
+ * inline under the list), so the commit never scrolls away inside a dialog.
113
+ * Two modes. Whole-set: the list, reviewed together. 1-BY-1 (when the host
114
+ * passes `onAcceptItem`/`onRejectItem`): each proposal is its own card with
115
+ * Keep / Drop; deciding COLLAPSES it to a preview with Undo. Where a
116
+ * `ReviewCard` reviews a SINGLE proposal, this is the BATCH engine.
92
117
  */
93
118
  export function ChangeReview<T>(props: ChangeReviewProps<T>) {
94
119
  const status = props.status ?? "open";
@@ -97,11 +122,13 @@ export function ChangeReview<T>(props: ChangeReviewProps<T>) {
97
122
  const statusFor = (item: T, i: number) => props.statusOf?.(item, i) ?? "pending";
98
123
  const keptCount = props.items.filter((it, i) => statusFor(it, i) === "accepted").length;
99
124
  const title = props.title ?? "Suggested edits";
100
-
101
- const acceptAll = () =>
102
- props.items.forEach((it, i) => {
103
- if (statusFor(it, i) === "pending") props.onAcceptItem?.(i);
104
- });
125
+ const L = {
126
+ undo: "Undo",
127
+ applied: "Applied",
128
+ discarded: "Discarded",
129
+ keptCount: (kept: number, total: number) => `${kept} of ${total} kept`,
130
+ ...props.labels,
131
+ };
105
132
 
106
133
  const summaryNote = props.summary ? (
107
134
  <View style={styles.note}>
@@ -111,6 +138,13 @@ export function ChangeReview<T>(props: ChangeReviewProps<T>) {
111
138
  </View>
112
139
  ) : null;
113
140
 
141
+ const resolvedLine =
142
+ status !== "open" ? (
143
+ <Text size="xs" color="muted" weight="medium">
144
+ {status === "applied" ? L.applied : L.discarded}
145
+ </Text>
146
+ ) : null;
147
+
114
148
  // ── 1-BY-1: each proposal a bordered card; deciding collapses it to a quiet,
115
149
  // lighter row — visual weight tracks whether it still needs you ──────────
116
150
  if (perItem) {
@@ -122,68 +156,55 @@ export function ChangeReview<T>(props: ChangeReviewProps<T>) {
122
156
  </Text>
123
157
  {status === "open" ? (
124
158
  <Text size="xs" color="muted" tabular>
125
- {keptCount} of {total} kept
159
+ {L.keptCount(keptCount, total)}
126
160
  </Text>
127
161
  ) : null}
128
162
  </View>
129
163
  {summaryNote}
130
164
 
131
165
  {status !== "open" ? (
132
- <Text size="xs" color="muted" weight="medium">
133
- {status === "applied" ? "Applied" : "Discarded"}
134
- </Text>
166
+ resolvedLine
135
167
  ) : (
136
- <>
137
- <View style={styles.list}>
138
- {props.items.map((item, i) => {
139
- const st = statusFor(item, i);
140
- if (st === "pending") {
141
- return (
142
- <View key={props.getKey(item, i)} style={styles.itemCard}>
143
- {props.renderItem(item, i)}
144
- <View style={styles.itemActions}>
145
- <Button title={props.rejectLabel ?? "Drop"} color="muted" shape="rounded" onPress={() => props.onRejectItem?.(i)} />
146
- <Button title={props.acceptLabel ?? "Keep"} color="secondary" shape="rounded" onPress={() => props.onAcceptItem?.(i)} />
147
- </View>
148
- </View>
149
- );
150
- }
151
- const kept = st === "accepted";
168
+ <View style={styles.list}>
169
+ {props.items.map((item, i) => {
170
+ const st = statusFor(item, i);
171
+ if (st === "pending") {
152
172
  return (
153
- <View key={props.getKey(item, i)} style={[styles.decidedRow, kept ? null : styles.decidedDrop]}>
154
- <Icon name={kept ? "check" : "x"} size={15} color={kept ? colors.emerald[600] : colors.zinc[400]} />
155
- <View style={{ flex: 1 }}>
156
- {props.renderSummary ? (
157
- props.renderSummary(item, kept, i)
158
- ) : (
159
- <Text size="sm" color={kept ? "default" : "muted"} numberOfLines={1} style={kept ? null : styles.strike}>
160
- {props.getTitle?.(item, i) ?? ""}
161
- </Text>
162
- )}
173
+ <View key={props.getKey(item, i)} style={styles.itemCard}>
174
+ {props.renderItem(item, i)}
175
+ <View style={styles.itemActions}>
176
+ <Button title={props.rejectLabel ?? "Drop"} color="muted" shape="rounded" onPress={() => props.onRejectItem?.(i)} />
177
+ <Button title={props.acceptLabel ?? "Keep"} color="secondary" shape="rounded" onPress={() => props.onAcceptItem?.(i)} />
163
178
  </View>
164
- {props.onUndoItem ? (
165
- <Button title="Undo" color="muted" shape="rounded" onPress={() => props.onUndoItem?.(i)} />
166
- ) : null}
167
179
  </View>
168
180
  );
169
- })}
170
- </View>
171
-
172
- <View style={styles.footer}>
173
- <Button title={props.acceptAllLabel ?? "Accept all"} color="muted" shape="rounded" onPress={acceptAll} />
174
- <View style={{ flex: 1 }} />
175
- {props.onDiscard ? <Button title="Discard" color="muted" shape="rounded" onPress={props.onDiscard} /> : null}
176
- {props.onApply ? (
177
- <Button title={props.applyLabel ?? "Apply kept"} color="primary" shape="rounded" disabled={keptCount === 0} onPress={props.onApply} />
178
- ) : null}
179
- </View>
180
- </>
181
+ }
182
+ const kept = st === "accepted";
183
+ return (
184
+ <View key={props.getKey(item, i)} style={[styles.decidedRow, kept ? null : styles.decidedDrop]}>
185
+ <Icon name={kept ? "check" : "x"} size={15} color={kept ? colors.emerald[600] : colors.zinc[400]} />
186
+ <View style={{ flex: 1 }}>
187
+ {props.renderSummary ? (
188
+ props.renderSummary(item, kept, i)
189
+ ) : (
190
+ <Text size="sm" color={kept ? "default" : "muted"} numberOfLines={1} style={kept ? null : styles.strike}>
191
+ {props.getTitle?.(item, i) ?? ""}
192
+ </Text>
193
+ )}
194
+ </View>
195
+ {props.onUndoItem ? (
196
+ <Button title={L.undo} color="muted" shape="rounded" onPress={() => props.onUndoItem?.(i)} />
197
+ ) : null}
198
+ </View>
199
+ );
200
+ })}
201
+ </View>
181
202
  )}
182
203
  </View>
183
204
  );
184
205
  }
185
206
 
186
- // ── WHOLE-SET: one bordered card, review the lot, apply / discard together
207
+ // ── WHOLE-SET: one bordered card, review the lot together ──────────────────
187
208
  return (
188
209
  <View style={[reviewCardStyle, status !== "open" ? reviewResolvedStyle : null]}>
189
210
  <Text size="xs" color="muted" weight="medium">
@@ -197,19 +218,43 @@ export function ChangeReview<T>(props: ChangeReviewProps<T>) {
197
218
  </View>
198
219
  ))}
199
220
  </View>
200
- {status !== "open" ? (
201
- <Text size="xs" color="muted" weight="medium">
202
- {status === "applied" ? "Applied" : "Discarded"}
203
- </Text>
204
- ) : (
205
- <View style={styles.footer}>
206
- <View style={{ flex: 1 }} />
207
- {props.onDiscard ? <Button title="Discard" color="muted" shape="rounded" onPress={props.onDiscard} /> : null}
208
- {props.onApply ? (
209
- <Button title={props.applyLabel ?? "Apply"} color="primary" shape="rounded" onPress={props.onApply} />
210
- ) : null}
211
- </View>
212
- )}
221
+ {resolvedLine}
222
+ </View>
223
+ );
224
+ }
225
+
226
+ /**
227
+ * The commit bar for a `ChangeReview` — Accept all (1-by-1) · Discard · Apply.
228
+ * SEPARATE from the list so the host pins it where commit bars belong: drop it
229
+ * into a `DialogFooter` / `DrawerFooter`, or render it inline under the list.
230
+ * It reads the same `items` + `statusOf` the list does, so it owns Accept-all
231
+ * and the apply-disabled-at-0-kept logic — the host never re-derives them.
232
+ * Renders a right-aligned button row (no divider — the footer container or an
233
+ * inline wrapper provides the chrome); self-hides once `status` isn't open.
234
+ */
235
+ export function ChangeReviewActions<T>(props: ChangeReviewActionsProps<T>) {
236
+ if ((props.status ?? "open") !== "open") return null;
237
+ const perItem = props.onAcceptItem != null;
238
+ const statusFor = (item: T, i: number) => props.statusOf?.(item, i) ?? "pending";
239
+ const keptCount = props.items.filter((it, i) => statusFor(it, i) === "accepted").length;
240
+ const acceptAll = () =>
241
+ props.items.forEach((it, i) => {
242
+ if (statusFor(it, i) === "pending") props.onAcceptItem?.(i);
243
+ });
244
+ return (
245
+ <View style={styles.actionsRow}>
246
+ {perItem ? <Button title={props.acceptAllLabel ?? "Accept all"} color="muted" shape="rounded" onPress={acceptAll} /> : null}
247
+ <View style={{ flex: 1 }} />
248
+ {props.onDiscard ? <Button title={props.discardLabel ?? "Discard"} color="muted" shape="rounded" onPress={props.onDiscard} /> : null}
249
+ {props.onApply ? (
250
+ <Button
251
+ title={props.applyLabel ?? (perItem ? "Apply kept" : "Apply")}
252
+ color="primary"
253
+ shape="rounded"
254
+ disabled={perItem && keptCount === 0}
255
+ onPress={props.onApply}
256
+ />
257
+ ) : null}
213
258
  </View>
214
259
  );
215
260
  }
@@ -246,7 +291,10 @@ const styles = {
246
291
  diff: { gap: 3 },
247
292
  diffLine: { flexDirection: "row", alignItems: "baseline", gap: 8 },
248
293
  strike: { textDecorationLine: "line-through" },
249
- footer: { flexDirection: "row", alignItems: "center", gap: 8, borderTopWidth: 1, borderTopColor: colors.zinc[200], paddingTop: 14 },
294
+ // The commit bar: a plain right-aligned row. The container (DialogFooter / an
295
+ // inline wrapper) owns any divider — the bar itself stays chrome-less so it
296
+ // nests cleanly in a footer without double borders. flex:1 fills the row.
297
+ actionsRow: { flexDirection: "row", alignItems: "center", gap: 8, flex: 1 },
250
298
  }),
251
299
  marker: (color: string) => ({ color, width: 12 }),
252
300
  value: (color: string) => ({ color }),
@@ -29,6 +29,17 @@ describe("shareOrDownloadFiles", () => {
29
29
  expect(shared.title).toBe("Ảnh");
30
30
  });
31
31
 
32
+ it("rides the given credentials on the blob fetch so an auth-gated proxy URL authorizes", async () => {
33
+ // Type the init param so mock.calls[0][1] is the RequestInit we assert on.
34
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(new Blob(["img"], { type: "image/png" })));
35
+ vi.stubGlobal("fetch", fetchMock);
36
+ vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn(async () => undefined) });
37
+
38
+ await shareOrDownloadFiles([{ url: "https://api.example/v1/files/k", filename: "a.png" }], { credentials: "include" });
39
+
40
+ expect(fetchMock.mock.calls[0][1]).toMatchObject({ credentials: "include" });
41
+ });
42
+
32
43
  it("treats a cancelled share sheet (AbortError) as done — never falls back to downloading", async () => {
33
44
  vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"], { type: "image/png" }))));
34
45
  const share = vi.fn(async () => { throw new DOMException("cancelled", "AbortError"); });