diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index cdd3ea03..1a8ad9a0 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2218,3 +2218,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, 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-148', 'status', 'in_progress', 'done', NULL, '2026-05-23 15:05:29', '2026-05-23 15:05:29', '2026-05-23 15:05:29', NULL, 'd9d9e4c96d94060f929c7c09935d127c', 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-141', 'status', 'in_progress', 'done', NULL, '2026-05-23 20:49:44', '2026-05-23 20:49:44', '2026-05-23 20:49:44', NULL, 'fb2405478b0d422d39a924ccedec5022', 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-157', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 20:52:47', '2026-05-23 20:52:47', '2026-05-23 20:52:47', NULL, 'b2b31ab5fe70fb5027773775b0945bd5', 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-157', 'status', 'in_progress', 'done', NULL, '2026-05-23 20:57:07', '2026-05-23 20:57:07', '2026-05-23 20:57:07', NULL, 'ce114090979d31512a9aa09f1c9311a1', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 663b1883..50d7a4ad 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2657,3 +2657,5 @@ Team-wide: Acceptance: roster matches list-panes/config; each row mirrors the member''s model + accept-edits/plan badge + context-usage; status verb and token deltas update live; account budget refreshes on its timer; panel appears only when a team is active. Depends on T-145 (status source) + T-139 (done). ', 'done', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-23 20:49:44', NULL, 'c9e79b9986f436abadba095d77d540f7', 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-157', 'task', 'T-132', 'Team sidebar: live per-member status (mode + context)', 'Extend the Claude meta sidebar (T-141) roster rows with each teammate''s LIVE status — permission-mode badge (default/accept-edits/plan/bypass) and context-token usage — mirroring the per-session strip (T-145/T-150). The data exists: TeamObserver creates a TranscriptPublisher per teammate whose statusStream emits SessionStatus, but it is NEVER published to the MessageBus. Wire it: in team_observer _joined, subscribe to the publisher''s statusStream and publish {agentId, model, permissionMode, contextTokens} to a new ClaudeConversation.memberStatusChannel; cancel on _left. The sidebar subscribes to that channel and updates the matching roster row. Do NOT re-tail transcripts (D-75) — reuse the existing stream. Acceptance: a running team shows each member''s mode badge + context tokens, updating live; module is the single status-forwarding point; tests; coverage >= floor.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-23 20:48:31', '2026-05-23 20:52:47', NULL, '386818f67d3cbcc1a1b7a528c2baaca1', 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-157', 'task', 'T-132', 'Team sidebar: live per-member status (mode + context)', 'Extend the Claude meta sidebar (T-141) roster rows with each teammate''s LIVE status — permission-mode badge (default/accept-edits/plan/bypass) and context-token usage — mirroring the per-session strip (T-145/T-150). The data exists: TeamObserver creates a TranscriptPublisher per teammate whose statusStream emits SessionStatus, but it is NEVER published to the MessageBus. Wire it: in team_observer _joined, subscribe to the publisher''s statusStream and publish {agentId, model, permissionMode, contextTokens} to a new ClaudeConversation.memberStatusChannel; cancel on _left. The sidebar subscribes to that channel and updates the matching roster row. Do NOT re-tail transcripts (D-75) — reuse the existing stream. Acceptance: a running team shows each member''s mode badge + context tokens, updating live; module is the single status-forwarding point; tests; coverage >= floor.', 'done', 'low', NULL, NULL, NULL, '2026-05-23 20:48:31', '2026-05-23 20:57:07', NULL, '6fd17798d24f81a3f81f284c7bd595e2', 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-159', 'bug', 'T-132', 'ClidePane convey notifies focus listeners during build (markNeedsBuild crash)', 'Found via make run (2026-05-24). ClidePane.didChangeDependencies/didUpdateWidget run during the build phase and call FocusTracker.setStatusWidget -> notifyListeners() synchronously, which rebuilds the focus-listening status-bar item (PaneContextStatusItem''s ListenableBuilder) mid-build -> ''setState()/markNeedsBuild() called during build'' (thrown repeatedly each frame). Introduced by T-150; the T-150 widget tests missed it because no focus listener was in their tree. Fix: ClidePane defers the convey to a post-frame callback when schedulerPhase == persistentCallbacks (mid-build), re-checking focus then; immediate otherwise. Regression test added with a PaneContextStatusItem in the tree while a ClidePane mounts focused.', 'backlog', 'high', NULL, NULL, NULL, '2026-05-24 08:53:13', '2026-05-24 08:53:13', NULL, '52caaaca78b7c2bf6cae907fcf0fcdae', 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 4593a47d..f6ae72ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,6 +145,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- Claude pane no longer floods the console with "markNeedsBuild called + during build" (T-159) — a focused pane surfacing its status-bar widget + now defers the notification out of the build phase instead of rebuilding + the status item mid-build. - The git / tickets / decisions / pql / problems tabs no longer log `i18n: namespace not registered` on boot (T-155). An extension's localized tab title is now loaded automatically on activation, and the diff --git a/lib/widgets/src/clide_pane.dart b/lib/widgets/src/clide_pane.dart index 4a42abfe..7a1ba703 100644 --- a/lib/widgets/src/clide_pane.dart +++ b/lib/widgets/src/clide_pane.dart @@ -12,6 +12,7 @@ library; import 'package:clide/kernel/src/facade.dart'; import 'package:clide/kernel/src/focus.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; class ClidePane extends StatefulWidget { @@ -68,12 +69,33 @@ class _ClidePaneState extends State { // notifies (→ _sync again), but the identical-widget guard there makes // the second pass a no-op. void _sync() { - if (_shown) _focus?.setStatusWidget(widget.contributionId, widget.statusWidget); + if (_shown) _convey(widget.statusWidget); + } + + // Push [w] to the focus tracker's status slot. setStatusWidget notifies + // focus listeners (the status-bar item rebuilds) — but didChangeDependencies + // and didUpdateWidget run during the build phase, where a synchronous notify + // would markNeedsBuild-during-build. So defer the convey to after the frame + // when we're mid-build, and re-check focus then (it may have moved). + void _convey(Widget? w) { + final focus = _focus; + if (focus == null) return; + void apply() { + if (focus.activeContributionId == widget.contributionId) { + focus.setStatusWidget(widget.contributionId, w); + } + } + + if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) { + WidgetsBinding.instance.addPostFrameCallback((_) => apply()); + } else { + apply(); + } } @override void dispose() { - if (_shown) _focus?.setStatusWidget(widget.contributionId, null); + if (_shown) _convey(null); _focus?.removeListener(_sync); super.dispose(); } diff --git a/test/widgets/src/clide_pane_test.dart b/test/widgets/src/clide_pane_test.dart index 3df6f6f0..08f132bc 100644 --- a/test/widgets/src/clide_pane_test.dart +++ b/test/widgets/src/clide_pane_test.dart @@ -2,6 +2,7 @@ /// FocusTracker slot only while it is the shown (focused + active) pane. library; +import 'package:clide/builtin/claude/src/pane_context_status.dart'; import 'package:clide/kernel/src/panels/slot_id.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; @@ -36,6 +37,30 @@ void main() { expect(focus.activeStatusWidget, isNull); // focus moved away → cleared }); + testWidgets('mounting while focused does not notify focus listeners during build', (tester) async { + // Regression: ClidePane.didChangeDependencies runs during the build phase, + // and conveying synchronously rebuilt the focus-listening status item + // mid-build ("markNeedsBuild during build"). A focus listener must be in + // the tree to reproduce it. + f.services.focus.setActive(slot: Slots.workspace, contributionId: 'pane.x'); + await tester.pumpWidget(harness( + f, + const Column( + children: [ + PaneContextStatusItem(), + ClidePane( + contributionId: 'pane.x', + statusWidget: Text('S', textDirection: TextDirection.ltr), + child: SizedBox(), + ), + ], + ), + )); + await tester.pump(); // run the deferred post-frame convey + expect(tester.takeException(), isNull); + expect(f.services.focus.activeStatusWidget, isA()); + }); + testWidgets('an inactive pane never conveys', (tester) async { await tester.pumpWidget(harness( f,