fix runtime exceptions surfaced when the editor opens
Now that the editor split actually opens (T-197), it exposed latent
issues, plus a coincidental Claude-session crash in the same log:
- T-203: the _EditorDragHandle's slider Semantics had value +
onIncrease/onDecrease but no increased/decreasedValue, so Flutter
asserted on every semantics flush — add them. And opening the split
reparented the Claude pane (direct child → Column/Expanded), tearing
down its SelectableRegion mid selection-update ('selectable not in
this registrar' / 'inactive element'); a stable GlobalKey on the
workspace primary makes Flutter move the element instead.
- T-202: rate_limit_event.resetsAt arrives as a unix-epoch number but
was cast `as String?`, throwing in the stream-json line parser. Accept
a num (epoch) or an ISO string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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;
|
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;
|
||||||
|
|||||||
@@ -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.
|
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);
|
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);
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- 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,
|
search-glass icon is now optional — so the Search tab's Find fields (search,
|
||||||
replace, include/exclude globs) are distinguishable instead of four identical
|
replace, include/exclude globs) are distinguishable instead of four identical
|
||||||
|
|||||||
+19
-3
@@ -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 {
|
class _WorkspaceSlot extends StatelessWidget {
|
||||||
const _WorkspaceSlot({required this.tabs, required this.active});
|
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 claude = tabs.where((t) => t.id == _claudeTabId).firstOrNull;
|
||||||
final primary = claude ?? active;
|
final primary = claude ?? active;
|
||||||
|
final primaryPane = KeyedSubtree(key: _kWorkspacePrimary, child: primary.build(ctx));
|
||||||
|
|
||||||
if (!editorOpen || editorTab == null) {
|
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;
|
final ratio = kernel.arrangement.editorRatio;
|
||||||
@@ -960,7 +970,7 @@ class _WorkspaceSlot extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(height: editorHeight, child: editorTab.build(ctx)),
|
SizedBox(height: editorHeight, child: editorTab.build(ctx)),
|
||||||
_EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight),
|
_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 tokens = ClideTheme.of(context).surface;
|
||||||
final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.panelBorder;
|
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(
|
return Semantics(
|
||||||
container: true,
|
container: true,
|
||||||
slider: true,
|
slider: true,
|
||||||
label: 'Editor split',
|
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),
|
onIncrease: () => _bump(_stepFine),
|
||||||
onDecrease: () => _bump(-_stepFine),
|
onDecrease: () => _bump(-_stepFine),
|
||||||
child: FocusableActionDetector(
|
child: FocusableActionDetector(
|
||||||
|
|||||||
@@ -597,17 +597,24 @@ class StreamJsonSession {
|
|||||||
final info = j['rate_limit_info'];
|
final info = j['rate_limit_info'];
|
||||||
if (info is Map) {
|
if (info is Map) {
|
||||||
final status = info['status'] as String?;
|
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) {
|
if (status != null) {
|
||||||
String label = 'rate limited';
|
String label = 'rate limited';
|
||||||
if (resetsAt != null) {
|
if (resetsTime != null) {
|
||||||
// Show just the time portion if it's an ISO timestamp.
|
final t = resetsTime.toLocal();
|
||||||
final t = DateTime.tryParse(resetsAt);
|
label = 'rate limited — resets ${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
||||||
if (t != null) {
|
} else if (resetsText != null) {
|
||||||
label = 'rate limited — resets ${t.toLocal().hour.toString().padLeft(2, '0')}:${t.toLocal().minute.toString().padLeft(2, '0')}';
|
label = 'rate limited — resets $resetsText';
|
||||||
} else {
|
|
||||||
label = 'rate limited — resets $resetsAt';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return SessionStatus(rateLimitInfo: label);
|
return SessionStatus(rateLimitInfo: label);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,6 +271,18 @@ void main() {
|
|||||||
expect(statuses.last.rateLimitInfo, contains('rate limited'));
|
expect(statuses.last.rateLimitInfo, contains('rate limited'));
|
||||||
expect(statuses.last.rateLimitInfo, contains('resets'));
|
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<void>.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)', () {
|
group('token streaming via stream_event (T-168, shape verified by T-184)', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user