Skip to content

Conversation

@andrewkolos
Copy link
Collaborator

@andrewkolos andrewkolos commented Dec 9, 2025

Description

This is a two-for-one change. Feel free to ask me to refactor this into two PRs.

Fixes #559. This isn't an air-tight fix, but I hope it is sufficient for now.

Changes

  • SurfaceUpdateTool wasn't parsing the value of the weight property and passing it to the Component constructor for any component it was constructing1. Definition of weight property:
    'weight': S.integer(
    description:
    'Optional layout weight for use in Row/Column children.',
    ),
    .
  • The widgetBuilder of the text field catalog component was modified. It now uses LayoutBuilder to determine if whether the width is unbounded, in which case a warning will be logged and the text field will be rendered in a SizedBox with a width of 300 pixels. I chose this width arbitrarily.
  • Hack: In the schema, the description of TextField and the description of weight now include a specific call out about TextField and how it needs its width bound. This creates an undesirable coupling between TextField and Component, and it feels wrong for Component to be aware of a downstream detail like TextField. However, this was the only way I found to get Gemini Flash 2.5 to respect this rule with any level of consistency (Pro 2.5 seems to be more rigorous and anticipate the need to use weight without explicit instruction). Making these explicit callouts may also impose more token burden on Flash (and similarly lightweight) models, possibility reducing the quality of the library's output elsewhere, but we currently do not have a way to measure this.

Repro

You can checkout this branch and create a basic test app with the following (LLM-generated) code:

Repro app code
import 'package:flutter/material.dart';
import 'package:genui/genui.dart';
import 'package:genui_google_generative_ai/genui_google_generative_ai.dart';

void main() {
  configureGenUiLogging();
  runApp(const ReproApp());
}

class ReproApp extends StatelessWidget {
  const ReproApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GenUI Repro',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const Home(),
    );
  }
}

class Home extends StatefulWidget {
  const Home({super.key});

  @override
  State<Home> createState() => _HomeState();
}

class _HomeState extends State<Home> {
  GenUiConversation? conversation;
  final _surfaceIds = <String>[];

  @override
  void initState() {
    super.initState();
    _startConversation();
  }

  void _startConversation() {
    final catalog = CoreCatalogItems.asCatalog();
    final generator = GoogleGenerativeAiContentGenerator(
      catalog: catalog,
      modelName: 'models/gemini-2.5-flash',
      systemInstruction: '''
Your job is to build UI using ONLY the built-in CoreCatalogItems.

Always generate:
1. A top search bar (TextField + Button)
2. A search results list
3. A chat message list
4. A bottom input area (TextField + Button)
''',
    );

    setState(() {
      conversation = GenUiConversation(
        genUiManager: GenUiManager(catalogs: [catalog]),
        contentGenerator: generator,
        onSurfaceAdded: (s) => setState(() => _surfaceIds.add(s.surfaceId)),
        onSurfaceDeleted: (s) =>
            setState(() => _surfaceIds.remove(s.surfaceId)),
        onError: (e) => print('Error: $e'),
      );
    });

    // Auto-trigger the generation
    conversation?.sendRequest(
      UserMessage([TextPart('Generate the UI please')]),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('GenUI Real Repro'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () {
              // Reset
              setState(() {
                _surfaceIds.clear();
                conversation?.dispose();
                conversation = null;
              });
              // Give it a moment to dispose before restarting
              Future.delayed(
                const Duration(milliseconds: 100),
                _startConversation,
              );
            },
          ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              itemCount: _surfaceIds.length,
              itemBuilder: (context, index) {
                return GenUiSurface(
                  host: conversation!.host,
                  surfaceId: _surfaceIds[index],
                );
              },
            ),
          ),
          if (conversation != null)
            ValueListenableBuilder(
              valueListenable: conversation!.isProcessing,
              builder: (context, isProcessing, _) {
                if (!isProcessing) return const SizedBox.shrink();
                return const LinearProgressIndicator();
              },
            ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          conversation?.sendRequest(
            UserMessage([TextPart('Generate the UI please')]),
          );
        },
        child: const Icon(Icons.play_arrow),
      ),
    );
  }
}

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-devrel channel on Discord.

Footnotes

  1. https://github.com/flutter/genui/blob/061ba8943160894a16ab0ffeaae99b7859e0ac49/packages/genui/lib/src/core/ui_tools.dart#L35-L37

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces two important fixes. First, it correctly parses the weight property in SurfaceUpdateTool, allowing components to be laid out with flex weights. This is well-tested with new unit tests for both integer and double values. Second, it prevents TextField from causing layout errors due to unbounded width by using a LayoutBuilder to detect this scenario, logging a warning, and applying a default width. This defensive measure is also accompanied by new widget tests. The changes are well-implemented and improve the robustness of the UI generation. I have one minor suggestion regarding test file imports to improve long-term maintainability.

@andrewkolos andrewkolos marked this pull request as ready for review December 9, 2025 21:56
@andrewkolos andrewkolos changed the title Pass missing weight property to Component constructor; Prevent unbounded text field width fix(genui): missing weight property to Component constructor; Prevent unbounded text field width Dec 9, 2025

final _schema = S.object(
description:
'A text input field. IMPORTANT: If this is placed inside a Row, '
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm I think we can't rely on this, because weight should always be optional. If I put a textField inside a row, shouldn't the row have a maximum width, and the textField be constrained to that? I know that is clearly not what's happening here, but that's what I'd hope for.

Can we do something like default the weight to 1.0 essentially, even if it isn't specified?

The overall goal here is to provide enough default behavior that the LLM can compose any element inside any other without complying with additional rules. The LLM here may be running on a server and not know the difference between flutter or lit etc, so we can't impose platform-specific constraints!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GenUI TextField crashes with “InputDecorator unbounded width” when generated using A2UI (Flutter GenUI v0.5.1)

2 participants