fix(ai): improvements on existing features - #2
Conversation
VelikovPetar
left a comment
There was a problem hiding this comment.
Looks pretty good from my testing! I left some new findings/recommendations, but as I don't think any of them is a blocker.
| /// Ends the current session, keeping what has been recognised so far. | ||
| Future<void> stop() async { | ||
| if (!_isListening) return; | ||
| await _speech.stop(); |
There was a problem hiding this comment.
I was able to reproduce a small bug on my Samsung A05s device:
- Tap the mic and start speaking
- Tap Stop within ~2s, before the first partial result has appeared
- Nothing ever lands in the text field
I believe it is because the plugin always delivers the final transcript after stop() — its docs even say "Stopping a listen session will cause a final result to be sent." But by then _setListening(false) has already cleared _onWords, so that result is thrown away.
I believe it can be fixed along the lines of: keep _onWords attached until the final result (or the done status) arrives; only cancel() should discard it. (isListening can still flip immediately for the UI.)
| /// its "All Photos" picker don't know about each other, so the same image | ||
| /// arriving twice is ordinary rather than exceptional, and it used to produce | ||
| /// two identical thumbnails. | ||
| List<XFile> addAttachments(Iterable<XFile> files) { |
There was a problem hiding this comment.
I think that we still have the issue of duplicates when picking the same attachment once from the inline picker, and once from "App photos" picker. But I don't consider this a blocker, as usually a customer will not be using both paths when selecting an image. Maybe something for the polishing phase.
| final cached = _fenceWidgetCache[key]; | ||
| if (cached != null) return cached; | ||
|
|
||
| if (_fenceWidgetCache.length >= _kFenceCacheCapacity) { |
There was a problem hiding this comment.
I think we should refresh an entry's recency on cache hits (a small LRU touch), because the insertion-order eviction can work against us while a fence is still streaming.
What I'm seeing: a fence that is still arriving inserts a new cache entry on every typewriter tick (its content grows each tick), and eviction removes the oldest entries — which at that point are the completed charts and code blocks above it, still on screen. The stale partial snapshots are the newest entries, so they stay. So while a later fence streams in, the visible fences above it can get evicted, re-parsed and rebuilt repeatedly.
Again, a small optimisation for an edge case, but perhaps one it makes sense to do.
`TypewriterController` tracked the target text and the reveal position as two
independent pieces of state, and `updateText` only replaced the former. When the
new text was shorter than what was already on screen, the char index was left
pointing past the end of it, so `startTyping` saw nothing left to reveal and
returned immediately — leaving the *previous* text displayed indefinitely. Any
replacement hit this: a regenerated or edited reply, or an error message
swapped in for a partial one.
Rather than patch that one path, every mutating method now goes through
`_reveal` and maintains a single invariant:
value.text == _targetText.take(_currentCharIndex).string
`updateText` treats text that starts with what is already displayed as a
continuation and types on from where it was (the streaming case, where each
chunk appends to the last); anything else is a replacement and is revealed from
the start. `stopTyping` now clears the revealed text along with the index, so a
following `startTyping` no longer jumps from the fully-revealed text back to its
first character. The constructor's off-by-one (`length - 1`, which cost a
duplicate frame on the first delta) is gone too.
Separately, `StreamingMessageView` never reported anything through
`onTypewriterStateChanged` for a view built with its complete text: it starts
fully revealed, so the controller never transitions and never notifies. A host
that clears a "generating" flag on `TypewriterState.idle` would wait forever.
The initial state is now reported once from a post-frame callback — deferred so
the host is free to rebuild in response without doing so during our build.
Also drops `test/stream_chat_flutter_ai_test.dart`, a placeholder asserting
`expect(true, isTrue)`, now that the controller has real coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The example app's own chart fence didn't parse. It was written as
{"type": "bar", "series": [{"label": "…", "points": [{"x": …, "y": …}]}]}
which matches no adapter: `_tryUSpec` requires `kind`, `_tryChartJs` requires
`data.datasets`, and ECharts/Highcharts require `xAxis`. So the example's "### A
chart" section rendered as a raw JSON code block. That is worth more than an
example fix — a model asked for a USpec will reach for the Chart.js vocabulary
it has seen far more of during training, exactly as the author of the example
did from memory. `kind`/`name` now accept `type`/`label` as aliases.
Widening the adapter's entry condition made an existing latent bug reachable:
`_tryUSpec` added a `USeries` even when it extracted no points from it, so a
payload that merely *looked* like a USpec was claimed here and rendered as an
empty chart instead of falling through. It now skips empty series, which is what
lets a `{type, xAxis, series}` payload still reach the ECharts adapter that
understands it. `y`/`size`/`z` also go through `_asDouble` like every other
adapter, so a quoted number (`"y": "12"`) is accepted.
Finally, the Vega-Lite adapter coerced a missing `y` to zero for every row, so a
spec whose `encoding` field names didn't match its data produced a chart of flat
zeroes that read as real measurements. It now skips unusable rows, which leaves
`groups` empty and falls through to a plain code block — the honest outcome, and
consistent with every other adapter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four separate ways a chart could misrepresent its `USpec`: - **Bar charts dropped and invented data.** The category count came from the *first* series, so any later series' extra points were silently discarded; and where a series had no point at a category, a rod was still emitted with `toY: 0`. A fabricated zero reads as a real measurement of nothing rather than as absent data. Categories now come from the longest series, and missing points produce no rod. - **Categorical scatter/bubble series were offset from each other.** The fallback x for a non-numeric label was `spots.length` — the running total across *all* series — so each series was pushed to the right of the one before it instead of sharing the same categories. It is now the point's index within its own series. These charts also passed an empty label list, reserving 28px for bottom-axis labels that never rendered; they now show category names, or the raw numeric values when every x parses as a number. - **Bubble sizes were clamped, not scaled.** `UPoint.size` was treated as though it were already a pixel radius and clamped to 6–40. Chart.js's `r` is pixels, but a USpec `size` is just as likely to be a population or a revenue figure — so every bubble past 40 came out identical, flattening the one encoding a bubble chart exists for. Sizes are now normalized across the chart before being mapped into the pixel range. - **A histogram of identical values rendered blank**, because `_makeBins` returned no bins when `max <= min`. It now returns a single bin. Dark theme, in the same files: the grid line and heatmap cell border were hardcoded translucent *black*, invisible on a dark surface, and now come from `colorScheme.outlineVariant`, with axis/row/column labels from `onSurfaceVariant`. The heatmap's sequential ramp also inverts in a dark theme — a light-to-dark ramp on a dark background makes the highest-value cells recede into it, inverting the intensity the color is meant to encode. Also replaces `List.contains` with a set when collecting heatmap columns, which was quadratic in the number of cells. The bottom-axis label callback now matches whole positions with a tolerance rather than truncating with `toInt()`, so fl_chart's fractional intermediate values no longer draw duplicate labels. Note these change rendered output, so `goldens/ci/` must be generated *after* this lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`AIMarkdownBody.build` called `_parse(data)` — a regex scan of the whole message — and then `USpecParser.tryParse` for every chart fence, which is a `jsonDecode` plus a walk through seven schema adapters. `StreamingMessageView` rebuilds once per typewriter tick, so a streaming reply containing a chart re-decoded that JSON roughly a hundred times a second, and paid for a throw-and-catch each time for any fence that wasn't chart data at all. Two changes, matched to how the two costs actually behave: - The widget keeps its segment list in `State` and re-scans only when `data` changes, so rebuilds from a theme change, a scroll, or an ancestor cost nothing. (This does mean the widget is now stateful.) - Chart-fence parses are memoized on the fence's exact content, which stops changing the moment its closing fence arrives — so the steady state during streaming is a map hit. Failures are cached too, since those are the expensive ones. The cache is bounded at 32 entries and evicts in insertion order; a streaming message appends fences, so the oldest is the least likely to still be on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`SpeechToTextButton` called `_speech.initialize()` from `initState`, and that is what triggers the microphone and speech-recognition permission prompts. So merely rendering a composer with `enableSpeechToText: true` asked the user for the microphone before they had shown any interest in dictating — and, because the composer swaps this widget out as soon as the field is non-empty, re-ran that initialization every time the field went from empty to typed and back. Initialization now happens on the first tap. The button also returned `SizedBox.shrink()` until initialization reported success. Since it occupies the composer's *trailing* slot, that left the slot completely empty on first frame — no mic, and no send button either — and permanently empty on any platform without a recognizer. It now always renders, and renders disabled if the recognizer turns out to be unavailable. The existing test asserted only `findsNothing` for the send icon, which passed precisely *because* nothing rendered at all. It now asserts the mic is present, plus a new test that spies on the plugin's method channel to pin down the part that actually matters: mounting the composer must not call `initialize`, and tapping the mic must. Two smaller fixes in the same widget: - `_onResult` assigned `textEditingController.text = words`, discarding whatever was already in the field. Harmless in the composer's own slot (only shown when the field is empty) but not when mounted via a factory, which the class docs recommend. Recognized words are now appended to the text present when listening began. - The pulse `AnimationController` was `repeat()`ing from `initState`, ticking every frame for the button's whole lifetime even though the animation is only used while listening. It now starts and stops with the listening state. The `ListenableBuilder` wrapper is gone as well: `build` reads nothing from the controller, so it was rebuilding on every keystroke for no reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two unrelated problems in the composer's image and button rendering. **Thumbnail futures were created inside `build`.** `_AttachmentThumbnail` called `file.readAsBytes()` there, and `ChatComposerController` notifies on every keystroke — so each character typed re-read every pending attachment off disk in full and re-decoded it, at full camera resolution, to fill a 64px box. The future now lives in `State` (re-created only when the file path changes) and `Image.memory` gets a `cacheWidth`, so decoding happens at thumbnail size. `_RecentPhotoTile` in the attachment sheet had the same shape: selecting one photo rebuilds the sheet, which re-requested all thirty visible thumbnails from the platform. **The circular buttons had no press feedback.** Each wrapped an `InkWell` around a `Container`/`AnimatedContainer` carrying an opaque decoration. Ink splashes paint on the nearest `Material` *below* the widget, so the decoration covered them and taps did nothing visible — on the "+" button, send/stop/mic, the thumbnail remove button, the option-chip dismiss button and the sheet's camera tile. Each now has a transparent `Material` *above* the fill for the ink to paint on. (`_SuggestionChip` already did this correctly and is unchanged.) The remove, dismiss and camera controls are icon-only and had no accessible name at all, so they gained tooltips, which is also what a screen reader announces. Those strings are hardcoded English like the rest of the package — they are listed in the roadmap's localization entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`AnimatedDots.spacing` was a public, documented parameter that did nothing — the `Row` was hardcoded to `spacing: 4`, which happened to match the default, so setting it had no effect. `_AnimatedDot.build` also constructed a `CurvedAnimation` and two `Tween`s on every call. Since these dots animate continuously that is once per frame, and a `CurvedAnimation` registers a listener on its parent and needs disposing, so it leaked one per frame as well. They are now built in `initState` and the curve is disposed. While here: the class doc referenced `[AI_STATE_THINKING]` and `[AI_STATE_CHECKING_SOURCES]`, constants that exist nowhere in this package — leftovers from `stream_chat_flutter`, where the AI state constants live. They rendered as dead links in the generated docs. The widget takes an arbitrary string, so the doc now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_measureContentWidth` binary-searches the narrowest width that still fits a label in two lines, laying out a `TextPainter` up to ~8 times per chip per build, and never disposed it — a `TextPainter` holds a native paragraph. It is now disposed in a `finally`, since the method has several early returns. The chip then rendered its label with `textAlign: TextAlign.left` despite threading the ambient `textDirection` all the way into the measurement above it, so in a right-to-left locale the text was measured correctly and then aligned to the wrong edge. Changed to `TextAlign.start`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`XFile` appears in this package's own public API — `ChatComposerSendCallback`'s third parameter and `ChatComposerController.attachments` — and is re-exported from the barrel file so callers don't have to reach for the picker themselves. It was being exported via `package:image_picker`, which only re-exports it from `cross_file`. Since the type is part of our API regardless of which picker it happens to arrive through, `cross_file` is now a direct dependency and the export comes from where the type is actually defined. Added under `melos.command.bootstrap` in the root pubspec as well, per the repo convention for dependency versions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`public_member_api_docs` makes a doc comment mandatory on every public member, but nothing checked that the `[links]` inside those comments resolve. Several didn't, and an unresolved reference doesn't fail anything — it just renders as plain text in the published dartdoc, so the rot is invisible until someone reads the docs on pub.dev. Enabling `comment_references` catches them, and with `--fatal-infos` it also guards the doc-only imports that some of those links depend on: this package's libraries are laid out so that the widget imports its controller, not the other way round, so referencing `[ChatComposer]` from `chat_composer_controller.dart` means importing it back. Dart is perfectly happy with the resulting cycle, and a doc-comment reference counts as a use so `unused_import` stays quiet — but an over-eager "organize imports" would otherwise silently break the link. Now it fails the build instead. Each such import carries a comment saying it exists for the docs. The individual reference fixes are in the preceding commits, alongside the code they document. This one adds the lint plus the last remaining file, whose only change is documentation: `chat_composer_controller.dart` gains the `chat_composer` import for `[ChatComposer]`, and imports `material` rather than `widgets` so `[TextField]` — the thing you actually pass its `textEditingController` to — resolves as well. Verified with `dart doc`: 0 warnings, 0 errors, and the references come out as real anchors, including to `api.flutter.dev` for `[TextField]`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The example handed `StreamingMessageView` the entire canned reply in one go. A view built with its complete text is already fully revealed, so the reply appeared instantly and the typewriter — the feature the example exists to show — never ran at all. Worse, the controller never transitioned, so `onTypewriterStateChanged` never fired, so the `isGenerating = false` wired to it never ran either: after the first reply the composer was stuck showing the stop button and the "Thinking" indicator stayed up forever. The fake backend now delivers the reply in chunks on a timer, which is both what a real streaming backend does and what the widget is designed around. Because the typewriter goes briefly idle whenever it catches up with the chunks received so far, "the reply is finished" is now the conjunction of two conditions — the backend has stopped sending *and* the typewriter has caught up — which is the same distinction a real integration has to make, so the example is a better model of one. The chart fence is also rewritten to a shape the parser accepts, with categorical x labels so the bar chart is labelled Mon–Fri rather than 1–5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Changelog: adds 🐞 Fixed and 🚀 Performance sections covering the preceding commits. Also adds a note under the 0.0.1 heading explaining what the 🔄 Changed entries are relative to — they describe renames and behaviour changes against unreleased code that lived on a branch in the `stream-chat-flutter` repo, which reads on pub.dev as though there were a previous release to migrate from. There isn't, and a first-time reader shouldn't have to work that out. Roadmap: several known issues belong to work already scheduled there, so they are recorded against it rather than patched in isolation. - **2.1 (code highlighting)** rewrites `CodeBlockView` wholesale, so the findings in that file are folded in as things to handle while it's open: `fontFamily: 'monospace'` doesn't resolve on iOS/macOS/web (only Android maps that generic name, so code blocks aren't monospaced at all elsewhere — and `test/flutter_test_config.dart` registers a system font purely to work around it in goldens, which should be revisited then), a `setState` after an `await` with no `mounted` guard, two racing reset timers on repeated copy taps, and no test coverage. Also corrects a stale `melos.yaml` reference — this repo is on Melos 8 and configures bootstrap from the root `pubspec.yaml`. - **2.3 (localization)** gets the complete table of hardcoded strings and their files, so it can be done in one pass instead of discovered piecemeal. Several are tooltips doubling as the only accessible label for an icon-only button, which makes that entry an accessibility fix too. - **2.4 (new)** — chart theming and accessibility. The dark-theme chrome that was outright broken is fixed, but the palette, chart height, bubble radius range and bin count are all still hardcoded constants with no way for a host to intervene, and a `ChartView` is an empty box to a screen reader despite having everything needed for a summary label sitting in the `USpec`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cc2bd54 to
8cc83b3
Compare
`MathBlockSyntax.parse` read the opening line, kept what sat between the delimiters and dropped everything after the closer. So `\[E = mc^2\] where m is mass.` rendered the expression and silently lost the sentence — content loss in an AI reply, not a layout glitch. It bites hardest outside of maths. `\[` at the start of a line is also CommonMark's escape for a literal `[`, so a citation list reaches this syntax: `\[1\] First source` collapsed to a bare `1`. A whole reference section turned into a column of numbers. The tail is now emitted as `UnparsedContent`, which the block parser's inline pass then walks — so it comes back as ordinary markdown, and a second expression on the same line (`\[a\] and \[b\]`) typesets too. An empty `\[\]` hands its source back rather than dropping the line, matching what `MathInlineSyntax` already did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… axes
Two problems, one symptom.
A Chart.js dataset of plain numbers with no `labels` key — a shape models emit
constantly — was only decoded in the branch that expects `{x, y, r}` objects,
so it yielded no points at all. Unlabelled numbers now take their position in
the array as the category, which is what Chart.js itself does.
And the empty series was still appended, so `series.isEmpty` was false and a
non-null `USpec` came back: `ChartView` drew an empty chart and the user's
numbers appeared nowhere on screen. `_tryUSpec` already guarded against exactly
this, with a comment explaining why — a payload an adapter cannot decode has to
fall through to one that can, or to a readable `CodeBlockView`. The Chart.js,
ECharts and Highcharts adapters now do the same.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Vega-Lite heatmap is `mark: "rect"` with `x` and `y` as the two axes and `color` carrying the cell value — the opposite of every other mark, where `color` names the series. Running it through the shared path used `color` as the series key and `y` as the value, and never set `UPoint.z`, so `HeatmapChartView` fell back to `y` for intensity. Given hours on one axis and a count in `color`, that produced one row per distinct count, columns of days, and the hour painted as the shade. Every axis was wrong, and the existing test only asserted `kind == heatmap`, so it passed. `rect` now gets its own mapping. A `rect` without a `color` encoding is declined rather than guessed at — there is no cell value to shade by, and inventing one draws a plausible grid out of data that says nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… series `null` in a Chart.js data array is the documented way to write a gap. The parser skips it, which leaves a series that is simply shorter — and points were then plotted at their position within their own list. So `[1, null, 3]` against labels Jan/Feb/Mar drew March's value above the February tick, out of step with the axis and with every other series in the chart. Rendering now keys off `UPoint.x` instead: `_categoryLabels` merges an axis across all series (longest first, so the fullest series sets the order and the rest only contribute what it is missing), and line, bar and scatter all look up each point's position on it. Labels aren't always usable as keys — a histogram's raw samples all carry the same empty `x` — so `_hasCategoryKeys` detects repeats and keeps the positional path for those. The previous fix in this area covered scatter only; this generalises it and fixes the gap case the parser can produce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ChatComposer.dispose` cancelled the recognition session only when
`enableSpeechToText` was true, but disposed its internally-owned controller
unconditionally on the very next line.
Those two conditions come apart in the arrangement `SpeechToTextButton`'s own
class documentation recommends: placing the button through a
`ChatComposerFactory`, which leaves `enableSpeechToText` at its default false.
Navigating away mid-dictation then left a live session holding `_onWords`, and
when its final transcript arrived — up to `finalTimeout + 500ms` later, by
design, so that stopping early doesn't throw the dictation away — it wrote into
a `TextEditingController` that was gone:
A TextEditingController was used after being disposed.
The cancel is now unconditional, and `_onWords` bails when the button is no
longer mounted. Either alone would close the crash; both, because the session
deliberately outlives the widget and a host can own the controller too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four faults in the recognition controller, all of them about a failure going somewhere the user can't see or come back from. `ensureInitialized` cached `false` as permanently as `true`. The usual reason initialization fails is the user declining the permission prompt, so the mic stayed disabled for the life of the process even after they granted access in Settings — stricter than `speech_to_text` itself, which retries after a failure. Only success is remembered now. That alone isn't enough, because `isAvailable == false` still renders the button disabled, so `invalidateAvailability()` is added and `SpeechToTextButton` calls it when the app returns to the foreground: coming back from Settings is exactly the moment to ask again. `start` set `isListening` only after awaiting `ensureInitialized`, and on first use that await spans the permission prompt — seconds, during which the button still renders as an enabled mic. Tapping twice reached `listen()` twice, which a real engine rejects as busy while the UI shows a session that isn't there. Concurrent callers now share one in-flight `initialize`, and `start` holds a guard across its awaits. Failures from `listen`, `stop` and `cancel` were rethrown into a tap handler, making them unhandled async errors — a console line in debug, silence in release — while `SpeechToTextConfig.onError`, added for precisely this, never fired. They are reported through it now. `SpeechRecognitionError` is re-exported so reacting to it doesn't mean depending on `speech_to_text` just to name the parameter's type, the same treatment `XFile` already gets. And `dispose()` asserts instead of running. It is inherited from `ChangeNotifier` and public, so a host disposing its controllers reflexively would brick dictation app-wide — surfacing much later, on an unrelated screen, as "used after being disposed". The permission-prompt paths need a recognizer that has never initialized successfully, which `SpeechToText` latches process-wide, so they get their own test file and their own isolate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Listenable.merge` returns a fresh `_MergingListenable` on every call and doesn't define `==`, so building it inside `build` made `ListenableBuilder` detach from and re-attach to both the composer controller and the speech singleton on every parent rebuild. Not a leak, and narrower than it first looks — `ListenableBuilder` is an `AnimatedWidget`, so notifications rebuild it internally without re-running the composer's `build` — but it is pointless churn. Held in state now, rebuilt only when the controller or `enableSpeechToText` actually changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…to access" The `catch` around `_loadRecentPhotos` spanned the permission request and both `photo_manager` calls, and mapped all of it to the same "Allow photo access" state. Only one kind of failure actually means that — no platform gallery implementation, on web or desktop or in a test. Anything else sent the user to Settings, where access was already granted, and back to the same empty strip, with no diagnostic anywhere to say what really happened. Genuine failures now go through `FlutterError.reportError`; the fallback UI is unchanged. `pickImage`/`pickMultiImage` had no handling at all. They throw `PlatformException` for an unavailable camera or a permission revoked mid-flight, and both call sites are tap handlers, so the error escaped into the zone and the tap simply did nothing. Both are guarded, and both now check the sheet is still mounted before adding to the controller — the picker is a separate activity on Android, so the host can be recreated behind it. Also adds `LruCache.peek`, and uses it for `_isSelected`. That runs from `build`, twice per tile, and `get` marks an entry most-recently-used — so painting was reordering the cache and recency tracked paint order rather than access, which is the opposite of what the eviction policy is for. While here, `XFile` is imported from `cross_file` rather than `image_picker`, matching the reasoning the barrel and the pubspec already give for depending on it directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`MarkdownWidget` parses in `initState` and re-parses only when `data` or `styleSheet` changes; it caches the built children and `build` just hands them back. So rebuilding the syntax lists in `didUpdateWidget` changed nothing that was ever consulted again, and flipping `useDollarDelimitersForMath` at runtime did nothing at all until the text happened to change. Streaming hid it — `data` changes every tick — but a finished message was stuck for good. The `MarkdownBody` is now re-keyed when the configuration changes. The comment claiming a changed `mathBuilder` is "picked up lazily" said the opposite of what happens: the closure is read during a *parse*, not a build. Reading it lazily is still right — hosts pass inline closures whose identity differs every build, and rebuilding the map would recompile `MathInlineSyntax`'s RegExp once per typewriter tick — so the behaviour stands and the comment now describes it. Adds `AIMarkdownBody.chartLanguages`, exporting the previously private default set. `json` is in it because models label chart data that way constantly, which also meant a plain ```json fence shaped like a chart spec was silently swallowed into a `ChartView` with no way to read the source, and no seam to opt out. The ROADMAP claimed that hook already existed; now it does. The `RepaintBoundary` assertion was tautological — `ModalRoute` already wraps every page in one, so `findsWidgets` over all ancestors passed whether or not the fence was wrapped. Since that boundary is what the streaming performance claim rests on, it now checks the immediate parent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`updateText` decided whether a chunk continues the text on screen with `String.startsWith`, a UTF-16 test, while `_currentCharIndex` counts grapheme clusters. The two disagree when a streaming chunk boundary lands inside a cluster: "👨" is a code-unit prefix of "👨👩👦" but not a grapheme one, so the chunk was taken for a continuation and the index was left past the end of a target one cluster long — `startTyping` returned immediately and the view froze until more text arrived. Also adds `finishTyping()`. `stopTyping()` resets, which empties the view, and `pauseTyping()` freezes it half-written — so a host wiring up a "stop generating" control had neither of the things that button wants, and the only way to reveal everything received so far was the `text` setter, which nobody finds from the name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dency The speech tests substitute their own `SpeechToTextPlatform` to drive the plugin, so they import `speech_to_text_platform_interface` directly. It only arrived transitively via `speech_to_text`, and `flutter pub publish -n` rejects an import the pubspec doesn't declare — which is what `melos run lint:pub` runs, and why the analyze job's Pub Check step was failing. Declared in both places, per the workspace convention: the version in the root `melos.command.bootstrap` list, the entry itself in the package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…diff The format job runs `melos bootstrap`, then `melos run format`, then asserts `git ls-files --modified` is empty. `dart format` was already clean — the diff came from bootstrap itself: `flutter pub get` injects an `analyzer.exclude` block for the platform and build directories into `analysis_options.yaml`, and re-resolves four SDK-constrained transitive packages that the committed lockfile predates. Neither is something a contributor edits, so the answer is to commit what the tool writes. Both hunks here are byte-identical to what CI produced (the run's own diff shows the same `1ba8b64..0f2585d` and `ddf0ae1..b70e94b` blobs). The root `analysis_options.yaml` block is included even though CI's Flutter 3.47.1 doesn't add it — 3.47.0 does, so checking it in keeps the tree clean on both rather than only on the runner. This failure predates the PR; it is on `main` too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This has quite a bit of fixes on the existing features before we continue adding new ones.
The commit messages should explain all the fixes that are in here.