@lotics/ui 5.3.0 → 5.4.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 CHANGED
@@ -83,8 +83,9 @@ Pick by capability, not by name. (→ the source file for the API.)
83
83
  `AgentRun` (the live streaming work feed) + `AgentProgress` (its compact, floating, expandable
84
84
  form — a composer's "working" state), `ReviewCard` (THE single-proposal review card — compositional
85
85
  body via `children`: a `RecordFields` record (header-first) or a `MatchSides` pairing (body-first))
86
- + `Confidence`; `ChangeReview` (the BATCH review engine — whole-set OR 1-by-1; compositional via
87
- `renderItem`, with `ChangeDiff` for the before→after body),
86
+ + `Confidence`; `ChangeReview` (the BATCH review list — whole-set OR 1-by-1; compositional via
87
+ `renderItem`, with `ChangeDiff` for the before→after body) + `ChangeReviewActions` (its commit bar,
88
+ pinned by the host in a `DialogFooter`/`DrawerFooter` or inline),
88
89
  `Clarify` (the agent asks back — selectable `ChoiceList` options),
89
90
  `Sources` (provenance chips for AI output). For STRUCTURED AI output the review
90
91
  surfaces above don't cover: `SpecList` (the "exact breakdown" — a labeled-value spec sheet, the
@@ -281,8 +282,13 @@ Compose the surfaces as a loop, and reach for the right one by job:
281
282
  → `tpl_match`). Stack several (rank by confidence) for a shortlist or a document's records.
282
283
  - **`ChangeReview`** — THE batch engine for a SET you decide each then commit together. `renderItem`
283
284
  draws each proposal (a `ChangeDiff` before→after body, a record, an editable form), 1-by-1
284
- Keep/Drop collapses each, "Accept all" + Apply commits only the kept (e.g. ingested rate lines —
285
- `tpl_ratedesk`); whole-set mode applies/discards the lot.
285
+ Keep/Drop collapses each (e.g. ingested rate lines — `tpl_ratedesk`); whole-set mode reviews the
286
+ lot. It is the LIST only — the commit bar (Accept all / Apply / Discard) is the SEPARATE
287
+ **`ChangeReviewActions`**, which the host places where commit bars belong (a `DialogFooter` /
288
+ `DrawerFooter`, or inline under the list). Don't bundle a commit into a scroll body — like every
289
+ other footer in the kit it's the container's to pin (`tpl_ratedesk` puts it in the `DialogFooter`).
290
+ `ChangeReviewActions` reads the same `items`/`statusOf`, so it owns Accept-all + the kept count —
291
+ never re-derive them in the host.
286
292
  The body atoms (`RecordFields`, `MatchSides`, `ChangeDiff`) are pure presentation — usable inside
287
293
  either engine OR standalone (a live-edit field card). The decide / accept-all / apply mechanics are
288
294
  never re-implemented per surface.
@@ -590,7 +596,7 @@ spacer · stack · section_card · page_header · page_content · calendar (cale
590
596
  comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
591
597
  expandable form — a composer's working state) · composer (Composer — the adaptive command/chat
592
598
  composer: a compact pill that expands for long text + attachments) · review_card (ReviewCard — THE single-proposal review card; compositional body via children + a generic actions footer; header-first with a title/eyebrow, body-first without) · confidence (calibrated high/med/low) ·
593
- record_fields (RecordFields — the editable extracted-record BODY for a ReviewCard, or a standalone live-edit field card) · change_review (ChangeReview — the BATCH review engine, whole-set OR 1-by-1; compositional `renderItem` body + the `ChangeDiff` before→after helper) ·
599
+ record_fields (RecordFields — the editable extracted-record BODY for a ReviewCard, or a standalone live-edit field card) · change_review (ChangeReview — the BATCH review LIST, whole-set OR 1-by-1; compositional `renderItem` body + the `ChangeDiff` before→after helper, `labels` localizes Undo/Applied/Discarded/counter — PLUS `ChangeReviewActions`, the separate commit bar the host pins in a `DialogFooter`/`DrawerFooter` or inline so it never scrolls away) ·
594
600
  clarify (the agent asks back, via ChoiceList) · choice_list (ChoiceList — selectable answer options,
595
601
  the agent's quick-reply surface) · sources (provenance chips, per-kind glyphs) · spec_list (SpecList — the exact
596
602
  labeled-value breakdown, the panel of an answer) · match_sides (MatchSides — the two-sided pairing BODY for a body-first ReviewCard reconciliation; composable side slots + the MatchSideText default) ·
@@ -4,7 +4,7 @@ import { colors } from "@lotics/ui/colors";
4
4
  import { Text } from "@lotics/ui/text";
5
5
  import { DotsIndicator } from "@lotics/ui/dots_indicator";
6
6
  import { Composer } from "@lotics/ui/composer";
7
- import { ChangeReview, ChangeDiff, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
7
+ import { ChangeReview, ChangeReviewActions, ChangeDiff, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
8
8
 
9
9
  // ─────────────────────────────────────────────────────────────────────────────
10
10
  // Template · AI assistant — a CHAT workflow, a different shape from the design
@@ -115,30 +115,42 @@ export function TplAssistant() {
115
115
  ) : null}
116
116
  {m.text ? <Text size="sm">{m.text}</Text> : null}
117
117
  {m.changes ? (
118
- <ChangeReview
119
- items={m.changes}
120
- getKey={(c, i) => `${m.id}:${i}`}
121
- statusOf={(_, i) => decisions[`${m.id}:${i}`] ?? "pending"}
122
- renderItem={(c) => (
123
- <>
124
- <Text size="xs" color="muted" weight="medium">{c.label}</Text>
125
- <ChangeDiff before={c.before} after={c.after} />
126
- </>
127
- )}
128
- renderSummary={(c, kept) => (
129
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
130
- <Text size="sm" weight="medium" numberOfLines={1}>{c.label}</Text>
131
- <Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={[{ flex: 1 }, kept ? undefined : { textDecorationLine: "line-through" }]}>{kept ? c.after : (c.before ?? c.after)}</Text>
118
+ <View style={{ gap: 14 }}>
119
+ <ChangeReview
120
+ items={m.changes}
121
+ getKey={(c, i) => `${m.id}:${i}`}
122
+ statusOf={(_, i) => decisions[`${m.id}:${i}`] ?? "pending"}
123
+ renderItem={(c) => (
124
+ <>
125
+ <Text size="xs" color="muted" weight="medium">{c.label}</Text>
126
+ <ChangeDiff before={c.before} after={c.after} />
127
+ </>
128
+ )}
129
+ renderSummary={(c, kept) => (
130
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
131
+ <Text size="sm" weight="medium" numberOfLines={1}>{c.label}</Text>
132
+ <Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={[{ flex: 1 }, kept ? undefined : { textDecorationLine: "line-through" }]}>{kept ? c.after : (c.before ?? c.after)}</Text>
133
+ </View>
134
+ )}
135
+ status={m.applied ? "applied" : "open"}
136
+ onAcceptItem={active ? (i) => decide(m.id, i, "accepted") : undefined}
137
+ onRejectItem={active ? (i) => decide(m.id, i, "rejected") : undefined}
138
+ onUndoItem={active ? (i) => setDecisions((d) => { const n = { ...d }; delete n[`${m.id}:${i}`]; return n; }) : undefined}
139
+ />
140
+ {/* Inline (no dialog) — the commit bar sits under the list behind a divider. */}
141
+ {active && !m.applied ? (
142
+ <View style={{ borderTopWidth: 1, borderTopColor: colors.zinc[200], paddingTop: 14, flexDirection: "row" }}>
143
+ <ChangeReviewActions
144
+ items={m.changes}
145
+ statusOf={(_, i) => decisions[`${m.id}:${i}`] ?? "pending"}
146
+ applyLabel="Apply accepted"
147
+ onAcceptItem={(i) => decide(m.id, i, "accepted")}
148
+ onApply={() => applyMsg(m.id)}
149
+ onDiscard={() => setMessages((ms) => ms.map((x) => (x.id === m.id ? { ...x, applied: true } : x)))}
150
+ />
132
151
  </View>
133
- )}
134
- status={m.applied ? "applied" : "open"}
135
- applyLabel="Apply accepted"
136
- onAcceptItem={active ? (i) => decide(m.id, i, "accepted") : undefined}
137
- onRejectItem={active ? (i) => decide(m.id, i, "rejected") : undefined}
138
- onUndoItem={active ? (i) => setDecisions((d) => { const n = { ...d }; delete n[`${m.id}:${i}`]; return n; }) : undefined}
139
- onApply={active ? () => applyMsg(m.id) : undefined}
140
- onDiscard={active ? () => setMessages((ms) => ms.map((x) => (x.id === m.id ? { ...x, applied: true } : x))) : undefined}
141
- />
152
+ ) : null}
153
+ </View>
142
154
  ) : null}
143
155
  </View>
144
156
  </View>
@@ -17,7 +17,7 @@ import { AgentRun, type AgentRunStep } from "@lotics/ui/agent_run";
17
17
  import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
18
18
  import { InlineNumberInput } from "@lotics/ui/inline_number_input";
19
19
  import { CompletionState } from "@lotics/ui/completion_state";
20
- import { ChangeReview, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
20
+ import { ChangeReview, ChangeReviewActions, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
21
21
  import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
22
22
  import { formatDate } from "@lotics/ui/format_date";
23
23
  import { colors, solid, type ColorName } from "@lotics/ui/colors";
@@ -254,12 +254,9 @@ export function TplRatedesk() {
254
254
  <Text size="xs" color="muted" numberOfLines={1} tabular style={[{ flex: 1 }, kept ? undefined : { textDecorationLine: "line-through" }]}>{l.carrier} · {l.unit} · sell {usd(l.sell)}</Text>
255
255
  </View>
256
256
  )}
257
- applyLabel={`Apply ${lines.filter((l) => decisions[l.id] === "accepted").length} kept`}
258
257
  onAcceptItem={(i) => decide(lines[i].id, "accepted")}
259
258
  onRejectItem={(i) => decide(lines[i].id, "rejected")}
260
259
  onUndoItem={(i) => decide(lines[i].id, null)}
261
- onApply={applyAccepted}
262
- onDiscard={reset}
263
260
  />
264
261
  ) : null}
265
262
 
@@ -268,21 +265,29 @@ export function TplRatedesk() {
268
265
  ) : null}
269
266
  </DialogScrollArea>
270
267
 
271
- {/* idle + done carry footer actions; reading streams and review owns its
272
- own Accept-all / Apply footer inside ChangeReview */}
273
- {phase === "idle" || phase === "done" ? (
268
+ {/* idle / review / done carry footer actions; reading just streams. The
269
+ review commit bar pins HERE via ChangeReviewActions — it never scrolls
270
+ away inside the list. */}
271
+ {phase === "idle" ? (
274
272
  <DialogFooter>
275
- {phase === "idle" ? (
276
- <>
277
- {files.length > 0 ? <Button title="Clear" color="muted" shape="rounded" onPress={() => setFiles([])} /> : null}
278
- <Button title={files.length ? `Read ${files.length} sheets` : "Read sheets"} color="primary" shape="rounded" disabled={!files.length} onPress={() => { setRevealed(0); setPhase("reading"); }} />
279
- </>
280
- ) : (
281
- <>
282
- <Button title="Import more" color="secondary" shape="rounded" onPress={reset} />
283
- <Button title="Done" color="primary" shape="rounded" onPress={closeImport} />
284
- </>
285
- )}
273
+ {files.length > 0 ? <Button title="Clear" color="muted" shape="rounded" onPress={() => setFiles([])} /> : null}
274
+ <Button title={files.length ? `Read ${files.length} sheets` : "Read sheets"} color="primary" shape="rounded" disabled={!files.length} onPress={() => { setRevealed(0); setPhase("reading"); }} />
275
+ </DialogFooter>
276
+ ) : phase === "review" ? (
277
+ <DialogFooter>
278
+ <ChangeReviewActions
279
+ items={lines}
280
+ statusOf={(l) => decisions[l.id] ?? "pending"}
281
+ onAcceptItem={(i) => decide(lines[i].id, "accepted")}
282
+ onApply={applyAccepted}
283
+ onDiscard={reset}
284
+ applyLabel={`Apply ${lines.filter((l) => decisions[l.id] === "accepted").length} kept`}
285
+ />
286
+ </DialogFooter>
287
+ ) : phase === "done" ? (
288
+ <DialogFooter>
289
+ <Button title="Import more" color="secondary" shape="rounded" onPress={reset} />
290
+ <Button title="Done" color="primary" shape="rounded" onPress={closeImport} />
286
291
  </DialogFooter>
287
292
  ) : null}
288
293
  </Dialog>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -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 }),