a2ui-project / a2ui-project/a2ui
Improve handling of loading states by deactiving parts of the UI or using scoped loading spinners
- Dominant language
- TypeScript
- Stars
- 16.4k
- Forks
- 1.3k
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 134
Description
Ported from https://github.com/flutter/genui/issues/267 — originally opened by [jacobsimionato](https://github.com/jacobsimionato) on 2025-09-01.
< -- >
Problem: Currently, when you are interacting with the demo apps, especially the travel app, it's possible to trigger many requests in parallel, which can create a confused Gen UI experience. For example, I trigger two itinerary generations simultaneously, and then they come back in quick succession and one overwrites the other. Or I click a button to generate content, but then also send a text message to generate content, and they both add too much content to the chat together.
In a traditional LLM app, there is only one input area - the text box, and so it's easy to just disable that when a request is in-flight, to avoid parallelism. Our problem is more complex than that because every UI input mechanism in the chat could trigger requests.
The best solution to this problem is probably use-case specific, e.g. developers can set up the behavior around the specific UI elements that they are using, and for the specific use case and interaction patterns they are supporting. So, I think we should solve this problem for the travel app first, adding any APIs we need to the core SDK to make things work nicely. We can be guided by Ryan's mocks at https://www.figma.com/design/L3YoeJ1VV6MG5hv2T31N0q/Flutter-Demo-apps?node-id=576-7292 - see the "travel" app section right at the bottom.
## Considerations
- We want to avoid unnecessarily deactivating huge parts of the UI while a request is in-flight
- The Core SDK should not prevent parallel requests as they could be an important feature later. But we need to give developers enough ability to control this.
- The solution could be implemented in the core SDK or in the specific catalog items, or both.
- The initial solution doesn't have to be perfect
- At the Core Gen UI SDK level, events can be triggered from the chat box or any input UI elements, and the results of that event can potentially update *any* UI element in the chat. This makes things hard, because if user triggers input from surface A, we then want to deactivate *all input surfaces which could produce output affecting any surfaces that the input from A could affect* which is *all* the surfaces.
- However most apps will have tighter constraints than that, e.g. you can only add new elements to the chat and never modify any, or an event from surface A can only update surface A or create a new surface - it's can't modify surface B. So maybe we can just deactivate the surface where the event was triggered for now.
## Alternative ideas
Not sure what is best, but some ideas that occurred to me
- Have items like filter_chip_group contain logic which puts them in a pending state when the "submit" button is clicked. Then, when the LLM response comes back, it updates it to new content, and if the filter chips are still present, they get reactivated. Not sure how to implement this well. I think it would probably need some ability to define state values which can be read/written by the LLM, but also by the widget logic itself, e.g. an isLoading required bool parameter which the LLM sets to false, but then the widget sets it to true on submission, this is reflected in the context passed on inference, and then the LLM sets it to false again. I think something like the UiDefinition would need to be updated, because it's important that if the widget tree rebuilds, the pending state won't be lost until new data from the LLM actually comes back.
- Alternative hack: Just have the stateful widget remember the last UiDefinition, and keep the spinner shown until some new UiDefinition is set which is *different*.
- Alternative: The LLM could make some token or timestamp associated with the inference for the UiDefinition be available to each widget, to make it easier for the widget to tell when something has changed, and whether that change was based on an inference sent *after* the pending state occurred and therefore includes the data that was submitted.
- Expose some way for each CatalogItem to know when a request caused by an event from that surface is pending
- Just have some setting on Surface that can deactivate the whole thing when an request is pending. Or expose a callback on Surface and let the developer build this themselves.
---
↴ Ported from [flutter/genui#267](https://github.com/flutter/genui/issues/267) — originally opened by [jacobsimionato](https://github.com/jacobsimionato) on 2025-09-01.
Original labels: P3
---
### 1 comment(s) from the original issue
**[andrewkolos](https://github.com/andrewkolos)** commented on 2025-09-22:
I have been holding my thoughts on this for a few days with the A2UI spike going on since that could end up entirely changing how this issue is handled. However, I think I should just get my thinking written down here instead of leaving it to slowly fade away in the back of my head. If we do move forward with the proposed A2UI protocol, perhaps we could adapt these thoughts to that implementation.
First off, I think it would cool to include at least one way to "repro". My go-to right now is to run `examples/simple_chat` and
1. prompt the chat with "Ask me a multiple-choice question about pizza", at which point the app pretty consistently replies with a radio group with the prompting text "What is generally considered the country of origin for pizza".
2. Choose and submit an answer (e.g. Italy). Wait for the response to come back (which is always a line of text telling me that I chose correctly).
3. prompt the chat with "Ask me another question about the country I picked," and then immediately submit a new answer to the "country of origin" question (e.g. China).
I find the LLM actually handles this situation quite well despite the ambiguity. After some amount of time, the app will eventually show two separate questions for China and Italy, and I can answer either one, and the LLM/UI reacts appropriately enough.
Moving on, I think the list of constraints you provided is spot on. We don't want the library to prevent parallel requests outright. This constraint takes all simple solutions off the table. In addition, the developer might want a surface to become disabled while its waiting a request that it initiated to resolve, or it may want to wait on a request initiated by another surface for any arbitrarily complex reason.
I think any solution we choose for this will need to involve the LLM (and some prompting from the developer). However, the big question here is how to distribute the complexity of disabling surfaces between the LLM and deterministic code written by the developer?
We can start at one end of the spectrum: I initially considered having the LLM reason about surface disabling behavior. For example, for each `GenUiSurface`, _S_, there exists a list of other surfaces, _L_, such that if any surface in *L* raises an LLM request, _S_ should be marked `busy` until that request resolves. The LLM (via the `addOrUpdateSurface` function) provides this list to the client, which would then rebuild surfaces/widgets with an updated `busy` parameter value. When determining what these rules should be, it would rely on system prompt instructions and common UI patterns in its training set.
However, most use cases will already be asking LLMs to do a lot of reasoning in figuring out the user persona and what UI elements to show to begin with. I'm very concerned that adding state management to this will make the prompt much more brittle[^1]. I imagine that this is similar to one of the reasons this is why `flutter_genui` has the concept of a widget catalog to begin with rather than generating arbitrary Dart code on the fly[^2].
Moving towards the other end of the spectrum, there's the idea you hinted at which is giving the ability to control disabling behavior via a Dart callback. This callback, which we'll name `requestScopeHandler`, could be passed to `UiAgent`:
```dart
// Defined in package:flutter_genui:
// Contains information
abstract class UserMessage {
String sourceSurfaceId;
}
// In the developer's application:
final myUiAgent = UiAgent(
// ...
requestScopeHandler: (RequestDescription request) {
// If a general text prompt is sent, disable all interactive surfaces.
if (sourceSurfaceId == null) {
// Maybe we should bake in a magic "all" value, bit this will do
// for the sake of demonstration.
return {'travel_preferences_form', 'final_itinerary'};
}
// Otherwise, just disable the surface that sent the event.
return {sourceSurfaceId};
}
// ...
```
To complete the setup, the system prompt would be instructed to always prefix surface IDs with the name of the component. `UiAgent` could then use them to determine which surfaces should be disabled. Widget builders would have a new `busy` parameter that `UiAgent` would use to rebuild widgets/surfaces in a "busy" or disabled state.
[^1]: Thinking about this more, I suppose developers already have the ability to prescribe arbitrarily complex UI behavior in the system prompt today (e.g. "when a user response starts with the letter 'j', ignore it and instead ask them a random multiple-choice question").
[^2]: This is not to discount the myriad of other reasons too, like SDK version incompatibility, syntactically-correct code being harder to generate than JSON following a well-constrained schema, difficulty in partially-updating a UI in response to new instructions provided by the LLM, etc.
---
Contributor guide
Research direction
Start by examining the travel app demo in examples/ and the core SDK's UiAgent and surface management. Look at the Figma mocks linked in the issue to understand the desired UI behavior. The solution likely involves adding a requestScopeHandler callback to UiAgent and a busy parameter to surfaces. Test changes in examples/simple_chat using the repro steps provided.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- dart, flutter, typescript
- Domain
- developer-experience, frontend
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100