diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 6f93cd0e..01f1e675 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -11,3 +11,5 @@ 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-197', 'description', 'Opening a file in the editor (the reader''s edit pencil, a non-.md file-tree click, a decision''s edit) calls editor.open and the daemon opens the buffer, but the editor pane never appears in the workspace slot above the Claude pane. Root cause: EditorExtension contributes a workspace tab (editor.active, priority 80) but has no activate() that reveals/activates it. editor.opened is emitted on the DaemonBus, but the EditorController that handles it only exists once EditorView is mounted — and nothing ever activates the editor tab to mount it. Fix: add EditorExtension.activate() subscribing to the editor.opened / editor.active-changed DaemonEvents and calling panels.activateTab(Slots.workspace, ''editor.active''). EditorView.hydrate() already pulls the active buffer on mount, so reveal-then-hydrate avoids any publish/subscribe race.', 'Opening a file in the editor (the reader''s edit pencil, a non-.md file-tree click, a decision''s edit) calls editor.open and the daemon opens the buffer, but the editor pane never appears in the workspace slot above the Claude pane. Root cause: EditorExtension contributes a workspace tab (editor.active, priority 80) but has no activate() that reveals/activates it. editor.opened is emitted on the DaemonBus, but the EditorController that handles it only exists once EditorView is mounted — and nothing ever activates the editor tab to mount it. Fix: add EditorExtension.activate() subscribing to the editor.opened / editor.active-changed DaemonEvents and calling panels.activateTab(Slots.workspace, ''editor.active''). EditorView.hydrate() already pulls the active buffer on mount, so reveal-then-hydrate avoids any publish/subscribe race. Reopened fix (2026-06-01): the original fix called panels.activateTab(Slots.workspace, ''editor.active''), but _WorkspaceSlot renders its editor split off arrangement.editorOpen, NOT the active tab — so the editor never appeared. Real fix: EditorExtension.activate now calls arrangement.openEditor() on editor.opened / active-changed(non-null) and closeEditor() on active-changed(null). Test asserts arrangement.editorOpen.', NULL, '2026-06-01 16:36:38', '2026-06-01 16:36:38', '2026-06-01 16:36:38', NULL, '62283198922795b643960d5beab56dba', 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-203', 'status', 'backlog', 'done', NULL, '2026-06-01 16:48:56', '2026-06-01 16:48:56', '2026-06-01 16:48:56', NULL, '2cd8d1f09a2cd432491f4ee1fecbadc0', 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-202', 'status', 'backlog', 'done', NULL, '2026-06-01 16:48:56', '2026-06-01 16:48:56', '2026-06-01 16:48:56', NULL, '2e730a377952b4ec2dd11561a97fcd00', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 8aa4ac65..85e9d039 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -9,3 +9,5 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, 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-197', 'bug', NULL, 'Editor pane never reveals on editor.open (edit button, file-tree open)', 'Opening a file in the editor (the reader''s edit pencil, a non-.md file-tree click, a decision''s edit) calls editor.open and the daemon opens the buffer, but the editor pane never appears in the workspace slot above the Claude pane. Root cause: EditorExtension contributes a workspace tab (editor.active, priority 80) but has no activate() that reveals/activates it. editor.opened is emitted on the DaemonBus, but the EditorController that handles it only exists once EditorView is mounted — and nothing ever activates the editor tab to mount it. Fix: add EditorExtension.activate() subscribing to the editor.opened / editor.active-changed DaemonEvents and calling panels.activateTab(Slots.workspace, ''editor.active''). EditorView.hydrate() already pulls the active buffer on mount, so reveal-then-hydrate avoids any publish/subscribe race. Reopened fix (2026-06-01): the original fix called panels.activateTab(Slots.workspace, ''editor.active''), but _WorkspaceSlot renders its editor split off arrangement.editorOpen, NOT the active tab — so the editor never appeared. Real fix: EditorExtension.activate now calls arrangement.openEditor() on editor.opened / active-changed(non-null) and closeEditor() on active-changed(null). Test asserts arrangement.editorOpen.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 08:58:19', '2026-06-01 16:36:38', NULL, '01039a9e83c3112ca93e33c228763b50', 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-202', 'bug', NULL, 'rate_limit_event with numeric resetsAt crashes the Claude session', 'stream_json_session._statusFromEvent cast info[''resetsAt''] as String?, but claude sends resetsAt as a unix-epoch NUMBER → ''int is not a subtype of String?'' unhandled in _onLine, killing the session''s line parsing. Fix: accept num (epoch seconds → DateTime.fromMillisecondsSinceEpoch) or String (ISO). Surfaced in a live session log.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 16:48:31', '2026-06-01 16:48:56', NULL, '589320cb1ad6a97a3d91c090ce4f2112', 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-203', 'bug', 'T-197', 'Editor split throws semantics + SelectableRegion exceptions on open', 'Opening the editor split (now that T-197 works) flooded two exceptions. (1) The _EditorDragHandle Semantics had slider:true + value + onIncrease/onDecrease but no increasedValue/decreasedValue → ''SemanticsNode with action increase needs both value and increasedValue'' on every flush; added increasedValue/decreasedValue. (2) Reparenting the Claude pane (direct child → Column/Expanded) when the split opens disposed its ClideSelectionArea/SelectableRegion subtree mid selection-update microtask → ''selectable not in this registrar'' + ''renderObject of inactive element''; fixed with a stable GlobalKey on the workspace primary so Flutter moves the element instead of disposing it.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 16:48:31', '2026-06-01 16:48:56', NULL, '77c284e1d35358783bda17955abba6b8', 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 35067d3a..8f7d1bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- Opening the editor split no longer floods exceptions: the resize handle's + slider semantics now carry increased/decreased values, and the Claude pane + keeps a stable identity across the reparent so its text-selection region + isn't torn down mid-update. (T-203) +- A `rate_limit_event` whose `resetsAt` is a numeric epoch no longer crashes the + Claude session — it was cast as a string. (T-202) - Filter/search inputs show their hint as visible placeholder text, and the search-glass icon is now optional — so the Search tab's Find fields (search, replace, include/exclude globs) are distinguishable instead of four identical diff --git a/lib/app.dart b/lib/app.dart index e4747aff..8fe49f6c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -923,6 +923,15 @@ class _SidebarSlot extends StatelessWidget { } } +// Stable identity for the workspace's primary pane (Claude). Opening the +// editor reparents it from a direct child into a Column/Expanded; without a +// stable key Flutter disposes + rebuilds the subtree, and the Claude +// conversation's SelectableRegion then runs a pending selection update +// against now-inactive elements ("selectable not in this registrar" / +// "renderObject of inactive element"). The GlobalKey makes Flutter MOVE the +// element instead, preserving the selection subtree. +final GlobalKey _kWorkspacePrimary = GlobalKey(debugLabel: 'workspace.primary'); + class _WorkspaceSlot extends StatelessWidget { const _WorkspaceSlot({required this.tabs, required this.active}); @@ -944,9 +953,10 @@ class _WorkspaceSlot extends StatelessWidget { final claude = tabs.where((t) => t.id == _claudeTabId).firstOrNull; final primary = claude ?? active; + final primaryPane = KeyedSubtree(key: _kWorkspacePrimary, child: primary.build(ctx)); if (!editorOpen || editorTab == null) { - return Container(color: tokens.panelBackground, child: primary.build(ctx)); + return Container(color: tokens.panelBackground, child: primaryPane); } final ratio = kernel.arrangement.editorRatio; @@ -960,7 +970,7 @@ class _WorkspaceSlot extends StatelessWidget { children: [ SizedBox(height: editorHeight, child: editorTab.build(ctx)), _EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight), - Expanded(child: primary.build(ctx)), + Expanded(child: primaryPane), ], ); }, @@ -998,11 +1008,17 @@ class _EditorDragHandleState extends State<_EditorDragHandle> { final tokens = ClideTheme.of(context).surface; final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.panelBorder; + final ratio = widget.arrangement.editorRatio; + String pct(double r) => '${(r.clamp(0.15, 0.70) * 100).round()}%'; return Semantics( container: true, slider: true, label: 'Editor split', - value: '${(widget.arrangement.editorRatio * 100).round()}%', + value: pct(ratio), + // increase/decrease actions require matching increased/decreased + // values, or Flutter asserts on every semantics flush. + increasedValue: pct(ratio + _stepFine), + decreasedValue: pct(ratio - _stepFine), onIncrease: () => _bump(_stepFine), onDecrease: () => _bump(-_stepFine), child: FocusableActionDetector( diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index 594f2bb0..c9682633 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -597,17 +597,24 @@ class StreamJsonSession { final info = j['rate_limit_info']; if (info is Map) { final status = info['status'] as String?; - final resetsAt = info['resetsAt'] as String?; + // `resetsAt` may arrive as a unix-epoch number (seconds) or an + // ISO string depending on the claude build — accept both. + final resetsRaw = info['resetsAt']; + DateTime? resetsTime; + String? resetsText; + if (resetsRaw is num) { + resetsTime = DateTime.fromMillisecondsSinceEpoch((resetsRaw * 1000).round(), isUtc: true); + } else if (resetsRaw is String) { + resetsTime = DateTime.tryParse(resetsRaw); + resetsText = resetsRaw; + } if (status != null) { String label = 'rate limited'; - if (resetsAt != null) { - // Show just the time portion if it's an ISO timestamp. - final t = DateTime.tryParse(resetsAt); - if (t != null) { - label = 'rate limited — resets ${t.toLocal().hour.toString().padLeft(2, '0')}:${t.toLocal().minute.toString().padLeft(2, '0')}'; - } else { - label = 'rate limited — resets $resetsAt'; - } + if (resetsTime != null) { + final t = resetsTime.toLocal(); + label = 'rate limited — resets ${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + } else if (resetsText != null) { + label = 'rate limited — resets $resetsText'; } return SessionStatus(rateLimitInfo: label); } diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index fc2dbe93..04461a45 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -271,6 +271,18 @@ void main() { expect(statuses.last.rateLimitInfo, contains('rate limited')); expect(statuses.last.rateLimitInfo, contains('resets')); }); + + test('rate_limit_event with a numeric (epoch) resetsAt does not crash', () async { + // Claude sends resetsAt as a unix-epoch number, not a string — the + // old `as String?` cast threw 'int is not a subtype of String?'. + proc.emit(jsonEncode({ + 'type': 'rate_limit_event', + 'rate_limit_info': {'status': 'rate_limited', 'resetsAt': 1780000000}, + })); + await Future.delayed(Duration.zero); + expect(statuses.last.rateLimitInfo, contains('rate limited')); + expect(statuses.last.rateLimitInfo, contains('resets')); + }); }); group('token streaming via stream_event (T-168, shape verified by T-184)', () {