harden SettingsStore against notify-after-dispose for fire-and-forget writes

set() doesn't await its file write, so a write in flight when the store is
disposed (app shutdown, or a closing test) would assert on a disposed
ChangeNotifier. Skip the post-write notify once disposed via a _disposed guard +
_safeNotify. Surfaced by the T-293 theme-persistence test flaking under the
loaded parallel run; also the correct behaviour for graceful shutdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 18:05:53 +02:00
co-authored by Claude Opus 4.8
parent 24364e6329
commit adbd8e285a
5 changed files with 62 additions and 5 deletions
@@ -1829,3 +1829,5 @@ RETRACTED the cancellation — NOT a duplicate of T-236 (per user, 2026-06-09).
DONE 2026-06-09 satisfied by D-89 together with T-236. The lightbox-expansion + path-retention (filename a11y label / unchanged copyText) intent from T-254 is delivered by the shared ImageThumbnail lightbox; the chosen presentation is inline (not a separate card) per the user. See T-236 / D-89 / commit.', NULL, '2026-06-09 15:55:06', '2026-06-09 15:55:06', '2026-06-09 15:55:06', NULL, 'c6090b1f5fdbdee4b56e6fd1ac39a9e2', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-254', 'status', 'ready', 'done', NULL, '2026-06-09 15:55:06', '2026-06-09 15:55:06', '2026-06-09 15:55:06', NULL, 'e07372e7e9662712183808199ac27210', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-297', 'status', 'backlog', 'ready', NULL, '2026-06-09 16:01:58', '2026-06-09 16:01:58', '2026-06-09 16:01:58', NULL, '12474d426c882ddf33efdd4fa6f86f95', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-296', 'status', 'backlog', 'ready', NULL, '2026-06-09 16:02:14', '2026-06-09 16:02:14', '2026-06-09 16:02:14', NULL, '8f3930428725856dcfd8042261852398', 1) ON CONFLICT(hash) DO NOTHING;
+40
View File
@@ -3146,3 +3146,43 @@ Repro:
Notes:
- Likely the scroll controller doesn''t react to the composer/interaction-zone height change (no re-scroll on viewport-inset/size change). Audit the conversation view''s scroll handling around interaction-zone show/hide (D-78) and on keyboard/box resize.
- Affects the Claude conversation panel.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-09 16:01:09', '2026-06-09 16:01:09', NULL, '5287b26bc163fd424808be2151796347', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-297', 'bug', 'T-276', 'Conversation doesn''t re-scroll when the bottom input resizes (content hides behind it)', 'When the bottom input area changes height, the conversation view does not re-scroll to the new viewport bottom, so the last bit of content is left hidden behind the newly-sized box.
Trigger: anything that resizes the bottom interaction zone (D-78) e.g. opening a permission dialog or the AskUserQuestion UI, which replace/expand the composer. The taller box shrinks the conversation viewport from the bottom, but the scroll offset isn''t adjusted, so content that was at the bottom edge is now occluded.
Expected: when the interaction zone grows, the conversation re-anchors so the previously-visible bottom content stays visible above the box (preserve bottom-anchoring / keep the tail in view). Symmetric on shrink no leftover gap when the box collapses back.
Repro:
1. Scroll the Claude conversation to the bottom (tail in view).
2. Trigger a permission prompt or AskUserQuestion (interaction zone expands).
3. Observed: a strip of the last message/card is hidden behind the enlarged input box.
4. Expected: view scrolls so that content remains fully visible above the box.
Notes:
- Likely the scroll controller doesn''t react to the composer/interaction-zone height change (no re-scroll on viewport-inset/size change). Audit the conversation view''s scroll handling around interaction-zone show/hide (D-78) and on keyboard/box resize.
- Affects the Claude conversation panel.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 16:01:09', '2026-06-09 16:01:58', NULL, '6508dfb893a7d12cad13e8d9e7b03718', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-296', 'task', 'T-276', 'Group consecutive edits to the same file into one collapsed card', 'When Claude makes multiple subsequent edits to the SAME file, bundle them into a single collapsed holder card instead of rendering each edit as its own card — mirroring how we already fold meta/activity runs.
Behaviour:
- Detect a run of consecutive edits targeting the same file_path and group them into one ClideHolderCard (the shared container from T-266; see _ActivityCard in lib/builtin/claude/src/conversation_view.dart:681).
- Collapsed (default): one-line ticker of the latest edit + a count label.
- Show the count as "# edits" (e.g. "3 edits" / "1 edit"), NOT "# steps". The existing activity card uses stepLabel = ''$count steps'' at conversation_view.dart:683; this run wants an edits-flavoured label.
- Expanded: every individual edit, each with its full report bundle, do NOT drop or summarise away any information. All per-edit detail must remain reachable on expand.
Notes:
- Reuse the existing ClideHolderCard / folding machinery rather than building a new card.
- Grouping breaks when the file_path changes or a non-edit step interleaves (consecutive-same-file only), matching the ''subsequent edits to the same file'' wording.
- Parity with the existing meta/activity folding (T-230) same collapse/expand affordance, just an edits-labelled run.
WORKED EXAMPLE (from user screenshot, clide_markdown.dart run): the current stream renders 11 stacked cards 3x "Edit clide_markdown.dart", then a folded "Read … 2 steps" holder, then 7x "Edit clide_markdown.dart". With this feature it collapses to THREE cards:
- [3 edits] (the first edit run)
- [2 steps] (the existing Read holder unchanged; this is what splits the edit run)
- [7 edits] (the second edit run)
Confirms the split rule: an interleaving non-edit step (here the folded Read run) breaks the consecutive-same-file edit grouping into two separate edit cards. Same-file edits with nothing between them collapse into one "# edits" card.
STATUS INDICATOR (live tick reuse): the bundled edits card carries ONE header status indicator that reuses the existing per-step success tick, driven by the latest edit''s state:
- edit in flight -> spinner
- edit completed -> success check (the current green tick)
- next edit starts -> back to spinner
- ...repeat, settling on check when the final edit in the run completes (or the error glyph if one fails).
So the collapsed card''s indicator animates spinner<->check as the run grows, rather than showing a static tick. Each individual edit keeps its own tick in the expanded list (unchanged); this is the aggregate indicator on the holder header/ticker. Mirror the same treatment for the existing "# steps" activity card if it doesn''t already do this.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-09 15:43:58', '2026-06-09 16:02:14', NULL, '79d8b599af75fc98290b7ab3751c077c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+19 -3
View File
@@ -14,6 +14,22 @@ class SettingsStore extends ChangeNotifier {
final Map<String, Object?> _appValues = <String, Object?>{};
final Map<String, Object?> _projectValues = <String, Object?>{};
// Writes are fire-and-forget (callers don't await `set`); if the store is
// disposed while one is mid-flight (app shutdown, a closing test), skip the
// post-write notify rather than asserting on a disposed ChangeNotifier.
bool _disposed = false;
@override
void dispose() {
_disposed = true;
super.dispose();
}
void _safeNotify() {
if (_disposed) return;
notifyListeners();
}
Future<void> load() async {
_appValues
..clear()
@@ -22,7 +38,7 @@ class SettingsStore extends ChangeNotifier {
if (projectDir != null) {
_projectValues.addAll(await _readFile(_projectFile));
}
notifyListeners();
_safeNotify();
}
Future<void> setProjectDir(Directory? dir) async {
@@ -31,7 +47,7 @@ class SettingsStore extends ChangeNotifier {
if (dir != null) {
_projectValues.addAll(await _readFile(_projectFile));
}
notifyListeners();
_safeNotify();
}
File get _appFile => File('${appDir.path}/settings.yaml');
@@ -73,7 +89,7 @@ class SettingsStore extends ChangeNotifier {
_appValues[key] = value;
await _writeFile(_appFile, _appValues);
}
notifyListeners();
_safeNotify();
}
Future<Map<String, Object?>> _readFile(File f) async {
@@ -3,7 +3,6 @@
library;
import 'package:clide/builtin/claude/src/image_thumbnail.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
+1 -1
View File
@@ -39,7 +39,7 @@ void main() {
});
tearDown(() async {
await pumpEventQueue(); // flush any in-flight unawaited set() file writes
await pumpEventQueue(); // let in-flight writes settle (dispose then no-ops any straggler)
settings.dispose();
theme.dispose();
for (final d in [appTmp, repoTmp]) {