Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.flow.component.ai.form;

import java.io.Serializable;
import java.util.Objects;

import com.vaadin.flow.component.HasValue;

/**
* Fired by {@link FormAIController} once per field whose value changed during a
* successful AI turn — either written by the LLM or by a cascade. Events fire
* in document order, after every field's post-turn value has been applied. A
* field whose post-turn value equals its pre-turn value (by
* {@link Objects#equals(Object, Object)}) does not produce an event. No events
* fire when the turn ended in error.
*
* @since 25.3
*/
public final class FieldValueChangeEvent implements Serializable {

private final transient FormAIController source;
private final HasValue<?, ?> field;
@SuppressWarnings("java:S1948")
private final Object oldValue;
@SuppressWarnings("java:S1948")
private final Object newValue;

FieldValueChangeEvent(FormAIController source, HasValue<?, ?> field,
Object oldValue, Object newValue) {
this.source = Objects.requireNonNull(source, "Source must not be null");
this.field = Objects.requireNonNull(field, "Field must not be null");
this.oldValue = oldValue;
this.newValue = newValue;
}

/**
* Returns the controller that produced this event. Listeners can use this
* to call back into the controller (e.g.
* {@link FormAIController#showFieldHighlight}) without capturing it from
* the registration site.
* <p>
* The source is transient, so it is {@code null} on an event restored from
* a serialized session.
*
* @return the source controller, or {@code null} if this event was
* deserialized
*/
public FormAIController getSource() {
return source;
}

/**
* Returns the field whose value changed during this turn.
*
* @return the changed field, never {@code null}
*/
@SuppressWarnings("java:S1452")
public HasValue<?, ?> getField() {
return field;
}

/**
* Returns the field's value at the start of the turn, before the LLM ran.
* For a field that was hidden at turn start and revealed during the turn,
* this is the field's actual pre-turn value rather than {@code null}. For a
* field added to the form during the turn, this is the field's
* {@link HasValue#getEmptyValue() empty value} — the field had no pre-turn
* value to report.
*
* @return the pre-turn value, possibly {@code null}
*/
public Object getOldValue() {
return oldValue;
}

/**
* Returns the field's value at the end of the turn, after the LLM's writes
* and any cascading value-change listeners have settled.
*
* @return the post-turn value, possibly {@code null}
*/
public Object getNewValue() {
return newValue;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.flow.component.ai.form;

import java.io.Serializable;

/**
* Listener for {@link FieldValueChangeEvent}s fired by a
* {@link FormAIController} after a successful AI turn. One event is delivered
* per changed field, in document order.
*
* @since 25.3
*/
@FunctionalInterface
public interface FieldValueChangeListener extends Serializable {

/**
* Invoked once per changed field after the AI turn has settled.
*
* @param event
* the event describing the change, never {@code null}
*/
void onFieldValueChange(FieldValueChangeEvent event);
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
import com.vaadin.flow.component.ai.provider.LLMProvider;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.selection.MultiSelect;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.internal.JacksonUtils;
import com.vaadin.flow.shared.Registration;

Expand Down Expand Up @@ -155,10 +154,10 @@
*
* <p>
* <b>Change tracking and highlight:</b> a listener registered through
* {@link #addFieldValueChangedListener(SerializableConsumer)} fires once per
* successful turn with the fields whose value changed during the turn — the
* common driver for {@link #showFieldHighlight(HasValue)} /
* {@link #hideFieldHighlight} to flash the AI's edits in the UI.
* {@link #addFieldValueChangeListener(FieldValueChangeListener)} fires once per
* field whose value changed during a successful turn — the common driver for
* {@link #showFieldHighlight(HasValue)} / {@link #hideFieldHighlight} to flash
* the AI's edits in the UI.
* </p>
*
* <p>
Expand Down Expand Up @@ -269,7 +268,7 @@ spans multiple fields (e.g. start date must precede end date). \
* {@link #hideFieldHighlight}.
*/
private final Map<HasValue<?, ?>, Registration> highlightedFields = new HashMap<>();
private final List<SerializableConsumer<List<FieldValueChange>>> fieldValuesChangedListeners = new ArrayList<>();
private final List<FieldValueChangeListener> fieldValueChangeListeners = new ArrayList<>();

/**
* Creates a new form AI controller for the given container. Fields are
Expand Down Expand Up @@ -613,40 +612,44 @@ public boolean isFieldValuesHidden() {
}

/**
* Registers a listener that is invoked once per successful turn with the
* fields whose value differs from what was read at the start of the turn.
* Registers a listener that is invoked once per field whose value changed
* during a successful AI turn. The listener fires once per changed field,
* in document order, after every field's post-turn value has been applied.
* Comparison is by {@link Objects#equals(Object, Object)} so multi-select
* sets, dates, and other value-objects work naturally.
* <p>
* Multiple listeners are supported and fire in registration order. If one
* listener throws, the exception is logged and the remaining listeners
* still fire.
* Multiple listeners are supported. For each changed field, every listener
* fires in registration order before the next field's event is dispatched.
* If one listener throws, the exception is logged and the remaining
* listeners still fire — both for that change and for subsequent changes in
* the same turn.
* <p>
* Only non-ignored fields are tracked, and only changed fields appear in
* the list. A field's pre-turn value is captured regardless of its current
* visibility, so a value cascaded into a freshly-revealed field is reported
* with the field's real pre-turn value rather than a spurious {@code null}.
* The listener is not called when the turn ended in error or when no field
* changed. The list iterates in document order; modifying it has no effect
* on the controller.
* Only non-ignored fields are tracked, and only fields whose value differs
* at end-of-turn produce events. A field's pre-turn value is captured
* regardless of its current visibility, so a value cascaded into a
* freshly-revealed field is reported with the field's real pre-turn value
* rather than a spurious {@code null}. A field added to the form during the
* turn is compared against its {@link HasValue#getEmptyValue() empty
* value}. No events fire when the turn ended in error.
* <p>
* The listener runs on the UI thread with the session lock held, so it can
* Listeners run on the UI thread with the session lock held, so they can
* update components and call {@link #showFieldHighlight} /
* {@link #hideFieldHighlight} directly without {@code ui.access(...)}. A
* typical use is to flash the AI's edits by calling
* {@code showFieldHighlight} on every changed field.
* {@code showFieldHighlight} on every event's
* {@link FieldValueChangeEvent#getField field}.
*
* @param listener
* the listener to register, not {@code null}
* @return a {@link Registration} that removes the listener when called
* @throws NullPointerException
* if {@code listener} is {@code null}
*/
public Registration addFieldValueChangedListener(
SerializableConsumer<List<FieldValueChange>> listener) {
public Registration addFieldValueChangeListener(
FieldValueChangeListener listener) {
Objects.requireNonNull(listener, "Listener must not be null");
fieldValuesChangedListeners.add(listener);
return () -> fieldValuesChangedListeners.remove(listener);
fieldValueChangeListeners.add(listener);
return () -> fieldValueChangeListeners.remove(listener);
}

/**
Expand Down Expand Up @@ -792,7 +795,7 @@ private void refreshValueOptionsBindings() {
@Override
public void onResponse(Throwable error) {
try {
fireFieldValuesChanged(error);
fireFieldValueChanges(error);
} finally {
// Unlock regardless of success or failure: locks set in onRequest
// must be released so the user can edit again. The failure path
Expand All @@ -804,7 +807,7 @@ public void onResponse(Throwable error) {
/**
* Captures the current value of every known field before the LLM runs. The
* snapshot is consulted in {@link #onResponse} to compute the before /
* after diff for {@link #addFieldValueChangedListener}. Skipped when no
* after diff for {@link #addFieldValueChangeListener}. Skipped when no
* listener is registered to avoid copying values that no one will read.
* <p>
* Hidden and disabled fields are included so a value cascaded into a field
Expand All @@ -813,7 +816,7 @@ public void onResponse(Throwable error) {
*/
private void snapshotPreTurnValues() {
preTurnValues.clear();
if (fieldValuesChangedListeners.isEmpty()) {
if (fieldValueChangeListeners.isEmpty()) {
return;
}
for (var field : collectKnownFields()) {
Expand All @@ -822,43 +825,58 @@ private void snapshotPreTurnValues() {
}

/**
* Builds the change list from the pre-turn snapshot and the post-turn value
* of every known field, then invokes every registered listener if anything
* changed. The post-turn walk picks up fields that were hidden (or absent)
* at turn start but became visible / were added during the turn, so
* visibility cascades report their value changes correctly. On error the
* snapshot is discarded and no listener fires — the application learns
* about errors through the orchestrator's response listener instead. A
* throwing listener is logged and otherwise ignored so subsequent listeners
* still fire and the rest of the response lifecycle (notably
* Walks the pre-turn snapshot against the post-turn value of every known
* field and fires one {@link FieldValueChangeEvent} per changed field, in
* document order. The post-turn walk picks up fields that were hidden (or
* absent) at turn start but became visible / were added during the turn, so
* visibility cascades report their value changes correctly. A field with no
* snapshot entry (added mid-turn) compares against its empty value, so
* adding a field that keeps its empty value does not produce an event.
* <p>
* The diff is materialised before any listener runs, so a listener that
* writes to a tracked field cannot retroactively change the
* {@code newValue} another field's event carries. The listener set is also
* snapshotted once per turn, so adding or removing listeners mid-dispatch
* (including a listener removing itself) affects only subsequent turns, not
* the rest of the current turn.
* <p>
* On error the snapshot is discarded and no events fire — the application
* learns about errors through the orchestrator's response listener instead.
* A throwing listener is logged and otherwise ignored so subsequent
* listeners still fire, subsequent change events in the same turn still
* fire, and the rest of the response lifecycle (notably
* {@link #unlockFields}) still runs.
*/
private void fireFieldValuesChanged(Throwable error) {
private void fireFieldValueChanges(Throwable error) {
if (preTurnValues.isEmpty() || error != null) {
preTurnValues.clear();
return;
}
var changes = new ArrayList<FieldValueChange>();
var events = new ArrayList<FieldValueChangeEvent>();
for (var field : collectKnownFields()) {
var oldValue = preTurnValues.get(field);
var oldValue = preTurnValues.containsKey(field)
? preTurnValues.get(field)
: field.getEmptyValue();
var newValue = field.getValue();
if (!Objects.equals(oldValue, newValue)) {
changes.add(new FieldValueChange(field, oldValue, newValue));
events.add(new FieldValueChangeEvent(this, field, oldValue,
newValue));
}
}
preTurnValues.clear();
if (changes.isEmpty()) {
if (events.isEmpty()) {
return;
}
// Snapshot the list before iterating so a listener that adds or
// removes listeners (its own Registration included) doesn't break
// the dispatch.
for (var listener : List.copyOf(fieldValuesChangedListeners)) {
try {
listener.accept(changes);
} catch (Exception ex) {
LOGGER.warn("Field-values-changed listener threw an exception",
ex);
var snapshot = List.copyOf(fieldValueChangeListeners);
for (var event : events) {
for (var listener : snapshot) {
try {
listener.onFieldValueChange(event);
} catch (Exception ex) {
LOGGER.warn(
"Field-value-change listener threw an exception",
ex);
}
}
}
}
Expand Down Expand Up @@ -912,8 +930,8 @@ private void lockFields() {
* tracks — i.e. all discovered fields minus those hidden via
* {@link #ignoreField(HasValue)}. Visibility and enabled state are NOT
* filtered, so this is the right set for the snapshot + diff used by
* {@link #addFieldValueChangedListener}: a field hidden at turn start may
* be revealed during the turn, and a value cascaded into it should compare
* {@link #addFieldValueChangeListener}: a field hidden at turn start may be
* revealed during the turn, and a value cascaded into it should compare
* against its real pre-turn value rather than {@code null}.
*/
private List<HasValue<?, ?>> collectKnownFields() {
Expand Down
Loading
Loading