@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/AGENTS.md +29 -16
- package/examples/tpl_assistant.tsx +36 -24
- package/examples/tpl_extract.tsx +1 -1
- package/examples/tpl_lookup.tsx +1 -1
- package/examples/tpl_pick.tsx +22 -14
- package/examples/tpl_ratedesk.tsx +24 -19
- package/package.json +1 -2
- package/src/agent_progress.tsx +14 -4
- package/src/agent_run.tsx +128 -133
- package/src/animation_fade_in.tsx +12 -5
- package/src/change_review.tsx +128 -80
- package/src/share_or_download.test.ts +11 -0
- package/src/stepper.tsx +206 -65
- package/src/step_list.tsx +0 -128
package/src/agent_run.tsx
CHANGED
|
@@ -1,174 +1,169 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
-
/**
|
|
13
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
|
|
28
|
+
/** The agent's streamed reasoning / answer text — shown 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
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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,
|
|
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:
|
|
54
|
-
{
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
<
|
|
106
|
+
<Stepper orientation="vertical" live={streaming}>
|
|
64
107
|
{steps.map((s, i) => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
13
|
+
const { children, style, translateY = 0 } = props;
|
|
14
|
+
const progress = useRef(new Animated.Value(0)).current;
|
|
12
15
|
|
|
13
16
|
useEffect(() => {
|
|
14
|
-
Animated.spring(
|
|
17
|
+
Animated.spring(progress, {
|
|
15
18
|
toValue: 1,
|
|
16
19
|
bounciness: 0,
|
|
17
20
|
useNativeDriver: true,
|
|
18
21
|
}).start();
|
|
19
|
-
}, [
|
|
22
|
+
}, [progress]);
|
|
20
23
|
|
|
21
|
-
|
|
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
|
}
|
package/src/change_review.tsx
CHANGED
|
@@ -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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
|
|
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.
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
159
|
+
{L.keptCount(keptCount, total)}
|
|
126
160
|
</Text>
|
|
127
161
|
) : null}
|
|
128
162
|
</View>
|
|
129
163
|
{summaryNote}
|
|
130
164
|
|
|
131
165
|
{status !== "open" ? (
|
|
132
|
-
|
|
133
|
-
{status === "applied" ? "Applied" : "Discarded"}
|
|
134
|
-
</Text>
|
|
166
|
+
resolvedLine
|
|
135
167
|
) : (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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={
|
|
154
|
-
|
|
155
|
-
<View style={
|
|
156
|
-
{props.
|
|
157
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
-
{
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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"); });
|