4747import com .vaadin .flow .component .ai .provider .LLMProvider ;
4848import com .vaadin .flow .data .binder .Binder ;
4949import com .vaadin .flow .data .selection .MultiSelect ;
50- import com .vaadin .flow .function .SerializableConsumer ;
5150import com .vaadin .flow .internal .JacksonUtils ;
5251import com .vaadin .flow .shared .Registration ;
5352
155154 *
156155 * <p>
157156 * <b>Change tracking and highlight:</b> a listener registered through
158- * {@link #addFieldValueChangedListener(SerializableConsumer )} fires once per
159- * successful turn with the fields whose value changed during the turn — the
160- * common driver for {@link #showFieldHighlight(HasValue)} /
161- * {@link #hideFieldHighlight} to flash the AI's edits in the UI.
157+ * {@link #addFieldValueChangeListener(FieldValueChangeListener )} fires once per
158+ * field whose value changed during a successful turn — the common driver for
159+ * {@link #showFieldHighlight(HasValue)} / {@link #hideFieldHighlight} to flash
160+ * the AI's edits in the UI.
162161 * </p>
163162 *
164163 * <p>
@@ -269,7 +268,7 @@ spans multiple fields (e.g. start date must precede end date). \
269268 * {@link #hideFieldHighlight}.
270269 */
271270 private final Map <HasValue <?, ?>, Registration > highlightedFields = new HashMap <>();
272- private final List <SerializableConsumer < List < FieldValueChange >>> fieldValuesChangedListeners = new ArrayList <>();
271+ private final List <FieldValueChangeListener > fieldValueChangeListeners = new ArrayList <>();
273272
274273 /**
275274 * Creates a new form AI controller for the given container. Fields are
@@ -613,40 +612,44 @@ public boolean isFieldValuesHidden() {
613612 }
614613
615614 /**
616- * Registers a listener that is invoked once per successful turn with the
617- * fields whose value differs from what was read at the start of the turn.
615+ * Registers a listener that is invoked once per field whose value changed
616+ * during a successful AI turn. The listener fires once per changed field,
617+ * in document order, after every field's post-turn value has been applied.
618618 * Comparison is by {@link Objects#equals(Object, Object)} so multi-select
619619 * sets, dates, and other value-objects work naturally.
620620 * <p>
621- * Multiple listeners are supported and fire in registration order. If one
622- * listener throws, the exception is logged and the remaining listeners
623- * still fire.
621+ * Multiple listeners are supported. For each changed field, every listener
622+ * fires in registration order before the next field's event is dispatched.
623+ * If one listener throws, the exception is logged and the remaining
624+ * listeners still fire — both for that change and for subsequent changes in
625+ * the same turn.
624626 * <p>
625- * Only non-ignored fields are tracked, and only changed fields appear in
626- * the list . A field's pre-turn value is captured regardless of its current
627- * visibility, so a value cascaded into a freshly-revealed field is reported
628- * with the field's real pre-turn value rather than a spurious {@code null}.
629- * The listener is not called when the turn ended in error or when no field
630- * changed. The list iterates in document order; modifying it has no effect
631- * on the controller .
627+ * Only non-ignored fields are tracked, and only fields whose value differs
628+ * at end-of-turn produce events . A field's pre-turn value is captured
629+ * regardless of its current visibility, so a value cascaded into a
630+ * freshly-revealed field is reported with the field's real pre-turn value
631+ * rather than a spurious {@code null}. A field added to the form during the
632+ * turn is compared against its {@link HasValue#getEmptyValue() empty
633+ * value}. No events fire when the turn ended in error .
632634 * <p>
633- * The listener runs on the UI thread with the session lock held, so it can
635+ * Listeners run on the UI thread with the session lock held, so they can
634636 * update components and call {@link #showFieldHighlight} /
635637 * {@link #hideFieldHighlight} directly without {@code ui.access(...)}. A
636638 * typical use is to flash the AI's edits by calling
637- * {@code showFieldHighlight} on every changed field.
639+ * {@code showFieldHighlight} on every event's
640+ * {@link FieldValueChangeEvent#getField field}.
638641 *
639642 * @param listener
640643 * the listener to register, not {@code null}
641644 * @return a {@link Registration} that removes the listener when called
642645 * @throws NullPointerException
643646 * if {@code listener} is {@code null}
644647 */
645- public Registration addFieldValueChangedListener (
646- SerializableConsumer < List < FieldValueChange >> listener ) {
648+ public Registration addFieldValueChangeListener (
649+ FieldValueChangeListener listener ) {
647650 Objects .requireNonNull (listener , "Listener must not be null" );
648- fieldValuesChangedListeners .add (listener );
649- return () -> fieldValuesChangedListeners .remove (listener );
651+ fieldValueChangeListeners .add (listener );
652+ return () -> fieldValueChangeListeners .remove (listener );
650653 }
651654
652655 /**
@@ -792,7 +795,7 @@ private void refreshValueOptionsBindings() {
792795 @ Override
793796 public void onResponse (Throwable error ) {
794797 try {
795- fireFieldValuesChanged (error );
798+ fireFieldValueChanges (error );
796799 } finally {
797800 // Unlock regardless of success or failure: locks set in onRequest
798801 // must be released so the user can edit again. The failure path
@@ -804,7 +807,7 @@ public void onResponse(Throwable error) {
804807 /**
805808 * Captures the current value of every known field before the LLM runs. The
806809 * snapshot is consulted in {@link #onResponse} to compute the before /
807- * after diff for {@link #addFieldValueChangedListener }. Skipped when no
810+ * after diff for {@link #addFieldValueChangeListener }. Skipped when no
808811 * listener is registered to avoid copying values that no one will read.
809812 * <p>
810813 * Hidden and disabled fields are included so a value cascaded into a field
@@ -813,7 +816,7 @@ public void onResponse(Throwable error) {
813816 */
814817 private void snapshotPreTurnValues () {
815818 preTurnValues .clear ();
816- if (fieldValuesChangedListeners .isEmpty ()) {
819+ if (fieldValueChangeListeners .isEmpty ()) {
817820 return ;
818821 }
819822 for (var field : collectKnownFields ()) {
@@ -822,43 +825,58 @@ private void snapshotPreTurnValues() {
822825 }
823826
824827 /**
825- * Builds the change list from the pre-turn snapshot and the post-turn value
826- * of every known field, then invokes every registered listener if anything
827- * changed. The post-turn walk picks up fields that were hidden (or absent)
828- * at turn start but became visible / were added during the turn, so
829- * visibility cascades report their value changes correctly. On error the
830- * snapshot is discarded and no listener fires — the application learns
831- * about errors through the orchestrator's response listener instead. A
832- * throwing listener is logged and otherwise ignored so subsequent listeners
833- * still fire and the rest of the response lifecycle (notably
828+ * Walks the pre-turn snapshot against the post-turn value of every known
829+ * field and fires one {@link FieldValueChangeEvent} per changed field, in
830+ * document order. The post-turn walk picks up fields that were hidden (or
831+ * absent) at turn start but became visible / were added during the turn, so
832+ * visibility cascades report their value changes correctly. A field with no
833+ * snapshot entry (added mid-turn) compares against its empty value, so
834+ * adding a field that keeps its empty value does not produce an event.
835+ * <p>
836+ * The diff is materialised before any listener runs, so a listener that
837+ * writes to a tracked field cannot retroactively change the
838+ * {@code newValue} another field's event carries. The listener set is also
839+ * snapshotted once per turn, so adding or removing listeners mid-dispatch
840+ * (including a listener removing itself) affects only subsequent turns, not
841+ * the rest of the current turn.
842+ * <p>
843+ * On error the snapshot is discarded and no events fire — the application
844+ * learns about errors through the orchestrator's response listener instead.
845+ * A throwing listener is logged and otherwise ignored so subsequent
846+ * listeners still fire, subsequent change events in the same turn still
847+ * fire, and the rest of the response lifecycle (notably
834848 * {@link #unlockFields}) still runs.
835849 */
836- private void fireFieldValuesChanged (Throwable error ) {
850+ private void fireFieldValueChanges (Throwable error ) {
837851 if (preTurnValues .isEmpty () || error != null ) {
838852 preTurnValues .clear ();
839853 return ;
840854 }
841- var changes = new ArrayList <FieldValueChange >();
855+ var events = new ArrayList <FieldValueChangeEvent >();
842856 for (var field : collectKnownFields ()) {
843- var oldValue = preTurnValues .get (field );
857+ var oldValue = preTurnValues .containsKey (field )
858+ ? preTurnValues .get (field )
859+ : field .getEmptyValue ();
844860 var newValue = field .getValue ();
845861 if (!Objects .equals (oldValue , newValue )) {
846- changes .add (new FieldValueChange (field , oldValue , newValue ));
862+ events .add (new FieldValueChangeEvent (this , field , oldValue ,
863+ newValue ));
847864 }
848865 }
849866 preTurnValues .clear ();
850- if (changes .isEmpty ()) {
867+ if (events .isEmpty ()) {
851868 return ;
852869 }
853- // Snapshot the list before iterating so a listener that adds or
854- // removes listeners (its own Registration included) doesn't break
855- // the dispatch.
856- for (var listener : List .copyOf (fieldValuesChangedListeners )) {
857- try {
858- listener .accept (changes );
859- } catch (Exception ex ) {
860- LOGGER .warn ("Field-values-changed listener threw an exception" ,
861- ex );
870+ var snapshot = List .copyOf (fieldValueChangeListeners );
871+ for (var event : events ) {
872+ for (var listener : snapshot ) {
873+ try {
874+ listener .onFieldValueChange (event );
875+ } catch (Exception ex ) {
876+ LOGGER .warn (
877+ "Field-value-change listener threw an exception" ,
878+ ex );
879+ }
862880 }
863881 }
864882 }
@@ -912,8 +930,8 @@ private void lockFields() {
912930 * tracks — i.e. all discovered fields minus those hidden via
913931 * {@link #ignoreField(HasValue)}. Visibility and enabled state are NOT
914932 * filtered, so this is the right set for the snapshot + diff used by
915- * {@link #addFieldValueChangedListener }: a field hidden at turn start may
916- * be revealed during the turn, and a value cascaded into it should compare
933+ * {@link #addFieldValueChangeListener }: a field hidden at turn start may be
934+ * revealed during the turn, and a value cascaded into it should compare
917935 * against its real pre-turn value rather than {@code null}.
918936 */
919937 private List <HasValue <?, ?>> collectKnownFields () {
0 commit comments