fix(editor): collapse the workspace split when the last buffer closes (T-459)

EditorRegistry.close() guarded its active-changed emit on `_activeId !=
null`, so closing the LAST buffer (active clears to null) emitted only
editor.closed — never the active-changed(id:null) the editor extension
listens for to call closeEditor(). editorOpen stayed true and the top
split sat orphaned over the Claude pane. Always emit active-changed when
the active buffer is removed, including the cleared-to-null case; the
slot renderer already collapses correctly once editorOpen flips false.

The existing extension test fabricated the null active-changed event, so
it passed despite the registry never emitting it — that gap is why the
bug shipped. Add a registry test that drives the real close() path, plus
a slot_host widget test asserting the split (drag handle) drops out and
the primary pane fills the column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 17:49:12 +02:00
co-authored by Claude Opus 4.8
parent 8c9c881d81
commit 1e49f3e1d9
6 changed files with 188 additions and 1 deletions
@@ -5947,3 +5947,7 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'description', NULL, 'Today clide uses Claude Code''s system-wide login (the CLI''s global auth), so every repo/workspace shares one Claude account. Allow selecting/logging into a separate Claude account per repo, so different projects can run under different accounts (e.g. personal vs work). Needs: per-workspace auth storage + a way for the spawned Claude session to use that repo''s account instead of the global one; UI to sign in/switch per repo; fall back to the system login when none is set.', NULL, '2026-06-19 07:42:08', '2026-06-19 07:42:08.761', '2026-06-19 07:42:08.761', NULL, '04b83ed9bd668d2cb22e75d18d1c2c94', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'description', NULL, 'Today clide uses Claude Code''s system-wide login (the CLI''s global auth), so every repo/workspace shares one Claude account. Allow selecting/logging into a separate Claude account per repo, so different projects can run under different accounts (e.g. personal vs work). Needs: per-workspace auth storage + a way for the spawned Claude session to use that repo''s account instead of the global one; UI to sign in/switch per repo; fall back to the system login when none is set.', NULL, '2026-06-19 07:42:08', '2026-06-19 07:42:08.761', '2026-06-19 07:42:08.761', NULL, '04b83ed9bd668d2cb22e75d18d1c2c94', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0TPYSWEM10RP0Q76XAP58', 'status', 'in_progress', 'done', NULL, '2026-06-19 08:20:44', '2026-06-19 08:20:44.218', '2026-06-19 08:20:44.218', NULL, 'f5289951d9bfdf8695a1cb77c212c3fb', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0TPYSWEM10RP0Q76XAP58', 'status', 'in_progress', 'done', NULL, '2026-06-19 08:20:44', '2026-06-19 08:20:44.218', '2026-06-19 08:20:44.218', NULL, 'f5289951d9bfdf8695a1cb77c212c3fb', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FEWB9JA5CMTXB5D7G1NSHAV4', 'status', 'backlog', 'in_progress', NULL, '2026-06-22 07:13:22', '2026-06-22 07:13:22.949', '2026-06-22 07:13:22.949', NULL, '6ab0f48756573af3095b195dadb38140', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FEWB9JA5CMTXB5D7G1NSHAV4', 'status', 'backlog', 'in_progress', NULL, '2026-06-22 07:13:22', '2026-06-22 07:13:22.949', '2026-06-22 07:13:22.949', NULL, '6ab0f48756573af3095b195dadb38140', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FEWB9JA5CMTXB5D7G1NSHAV4', 'status', 'in_progress', 'done', NULL, '2026-06-22 07:14:14', '2026-06-22 07:14:14.574', '2026-06-22 07:14:14.574', NULL, 'e7004e7a1a3aa06466cc87fad6e72da7', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'status', 'backlog', 'in_progress', NULL, '2026-06-22 10:20:50', '2026-06-22 10:20:50.137', '2026-06-22 10:20:50.137', NULL, '438dbb6a04a7858c26c827dfe236bf5e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'status', 'in_progress', 'in_progress', NULL, '2026-06-22 10:21:01', '2026-06-22 10:21:01.539', '2026-06-22 10:21:01.539', NULL, 'f6c39e32ded6c1a0a23248c93daed0af', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'status', 'in_progress', 'done', NULL, '2026-06-22 15:48:43', '2026-06-22 15:48:43.008', '2026-06-22 15:48:43.008', NULL, '2750ec604cb4c00ba21be9a69592bfeb', 2) ON CONFLICT(hash) DO NOTHING;
+76
View File
@@ -7381,3 +7381,79 @@ This epic = migrate the ~144 hardcoded labels to i18n, per extension, extending
Audit per-extension hardcoded counts (approx): claude 35, welcome 15, git 9, diff 7, tickets 6, pql 6, menubar 6, decisions 6, output 4, search 3, problems 3, deeplink 3, editor 1, files 1, terminal 1, markdown 1, vim 1; lib/widgets 9; lib/kernel 1.', 'done', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:06.422', '2026-06-19 08:20:44.217', NULL, '8ee6f221b3d13163bbe9e149111aca64', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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); Audit per-extension hardcoded counts (approx): claude 35, welcome 15, git 9, diff 7, tickets 6, pql 6, menubar 6, decisions 6, output 4, search 3, problems 3, deeplink 3, editor 1, files 1, terminal 1, markdown 1, vim 1; lib/widgets 9; lib/kernel 1.', 'done', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:06.422', '2026-06-19 08:20:44.217', NULL, '8ee6f221b3d13163bbe9e149111aca64', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FEWB9JA5CMTXB5D7G1NSHAV4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Welcome status-line overlaps tips card at 720p default window height', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-22 07:13:19.953', '2026-06-22 07:13:19.953', NULL, '9197ed499e6cdfe03de02e8fff10655c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FEWB9JA5CMTXB5D7G1NSHAV4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Welcome status-line overlaps tips card at 720p default window height', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-22 07:13:19.953', '2026-06-22 07:13:19.953', NULL, '9197ed499e6cdfe03de02e8fff10655c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FEWB9JA5CMTXB5D7G1NSHAV4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Welcome status-line overlaps tips card at 720p default window height', NULL, 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-22 07:13:19.953', '2026-06-22 07:13:22.944', NULL, '3c03377e8054d2fa676c5bc9a38604e1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FEWB9JA5CMTXB5D7G1NSHAV4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Welcome status-line overlaps tips card at 720p default window height', NULL, 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-22 07:13:19.953', '2026-06-22 07:13:22.944', NULL, '3c03377e8054d2fa676c5bc9a38604e1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FEWB9JA5CMTXB5D7G1NSHAV4', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Welcome status-line overlaps tips card at 720p default window height', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-22 07:13:19.953', '2026-06-22 07:14:14.574', NULL, '9132310dd6f17ca0569afe5b2a4893f8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor top split in main column does not collapse when its last panel closes', 'Closing the last editor panel in the top split of the main column leaves an empty/broken region instead of collapsing so the primary pane fills the column. Suspect: asymmetric top/bottom split logic in slot_host.dart _WorkspaceSlot, and closeEditor() not switching the active tab off editor.active. Details appended below.
## Repro
In the main column (the center "workspace" slot), the editor renders as a **top split** above the primary pane (Claude). Close the last editor panel/buffer in that top split (e.g. `:q`, `ctrl+w`, or closing the last editor tab).
- **Expected:** the top split collapses and the remaining primary pane takes the full column height.
- **Actual:** the top split does not collapse cleanly leftover/empty space or broken layout remains where the editor split was.
## Suspect code paths (root cause not yet confirmed)
- `lib/src/shell/slot_host.dart` `_WorkspaceSlot.build()` (~lines 147-206). The split is computed asymmetrically: the **top** region is dynamic `topTab = reveal ?? (editorOpen ? editorTab : null)` (~line 176), collapsing only when `topTab == null` (~line 178) while the **bottom** region is always `primaryPane = (claude ?? active)`. The top/bottom asymmetry is the prime suspect for why the top split specifically fails to collapse.
- `lib/kernel/src/panels/arrangement.dart` `closeEditor()` (~line 108) flips `editorOpen = false` but does not switch the workspace slot''s **active tab** away from `editor.active`. The editor tab stays registered, so a rebuild may still resolve `active`/`editorTab` to the editor and keep the split open.
- `lib/builtin/editor/src/extension.dart` (~lines 36-43) the `editor.active-changed` handler calls `ctx.arrangement.closeEditor()` when the last buffer closes (`id == null`). Confirm this fires and that the resulting rebuild actually reaches the collapse branch.
- Possible rebuild-notification gap: `_WorkspaceSlot`''s `ListenableBuilder` listens to `kernel.arrangement`; verify active-tab changes (`kernel.panels`) also drive the rebuild that recomputes `topTab`.
## Investigation / fix direction
1. Reproduce in the running app and pin down which of the above is the actual cause (asymmetric top/bottom split logic vs. active tab not switching away from the closed editor).
2. Make collapse symmetric and state-driven: when the editor top split has no remaining panel, the region must drop out so the primary pane fills the column independent of which tab is "active".
3. On editor close, ensure the workspace active tab is moved off `editor.active` (or that the split renderer ignores a closed editor tab regardless of active state).
## Tests
Existing coverage (`test/builtin/editor/editor_extension_test.dart`, `test/builtin/default_layout/widget_test.dart`, `test/app_test.dart`) asserts the `editorOpen` flag toggles but does **not** assert the rendered split actually collapses with no leftover region. Add a widget test that opens the editor top split, closes the last panel, and asserts the workspace renders only the primary pane (no empty split / drag handle remaining).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 14:15:25.011', '2026-06-22 10:20:50.137', NULL, '709913e8fecbcdf6932cf39a5354b990', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor top split in main column does not collapse when its last panel closes', 'Closing the last editor panel in the top split of the main column leaves an empty/broken region instead of collapsing so the primary pane fills the column. Suspect: asymmetric top/bottom split logic in slot_host.dart _WorkspaceSlot, and closeEditor() not switching the active tab off editor.active. Details appended below.
## Repro
In the main column (the center "workspace" slot), the editor renders as a **top split** above the primary pane (Claude). Close the last editor panel/buffer in that top split (e.g. `:q`, `ctrl+w`, or closing the last editor tab).
- **Expected:** the top split collapses and the remaining primary pane takes the full column height.
- **Actual:** the top split does not collapse cleanly leftover/empty space or broken layout remains where the editor split was.
## Suspect code paths (root cause not yet confirmed)
- `lib/src/shell/slot_host.dart` `_WorkspaceSlot.build()` (~lines 147-206). The split is computed asymmetrically: the **top** region is dynamic `topTab = reveal ?? (editorOpen ? editorTab : null)` (~line 176), collapsing only when `topTab == null` (~line 178) while the **bottom** region is always `primaryPane = (claude ?? active)`. The top/bottom asymmetry is the prime suspect for why the top split specifically fails to collapse.
- `lib/kernel/src/panels/arrangement.dart` `closeEditor()` (~line 108) flips `editorOpen = false` but does not switch the workspace slot''s **active tab** away from `editor.active`. The editor tab stays registered, so a rebuild may still resolve `active`/`editorTab` to the editor and keep the split open.
- `lib/builtin/editor/src/extension.dart` (~lines 36-43) the `editor.active-changed` handler calls `ctx.arrangement.closeEditor()` when the last buffer closes (`id == null`). Confirm this fires and that the resulting rebuild actually reaches the collapse branch.
- Possible rebuild-notification gap: `_WorkspaceSlot`''s `ListenableBuilder` listens to `kernel.arrangement`; verify active-tab changes (`kernel.panels`) also drive the rebuild that recomputes `topTab`.
## Investigation / fix direction
1. Reproduce in the running app and pin down which of the above is the actual cause (asymmetric top/bottom split logic vs. active tab not switching away from the closed editor).
2. Make collapse symmetric and state-driven: when the editor top split has no remaining panel, the region must drop out so the primary pane fills the column independent of which tab is "active".
3. On editor close, ensure the workspace active tab is moved off `editor.active` (or that the split renderer ignores a closed editor tab regardless of active state).
## Tests
Existing coverage (`test/builtin/editor/editor_extension_test.dart`, `test/builtin/default_layout/widget_test.dart`, `test/app_test.dart`) asserts the `editorOpen` flag toggles but does **not** assert the rendered split actually collapses with no leftover region. Add a widget test that opens the editor top split, closes the last panel, and asserts the workspace renders only the primary pane (no empty split / drag handle remaining).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 14:15:25.011', '2026-06-22 10:21:01.538', NULL, 'df103b4c79bb6ab64bc0139e8ad7b7eb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor top split in main column does not collapse when its last panel closes', 'Closing the last editor panel in the top split of the main column leaves an empty/broken region instead of collapsing so the primary pane fills the column. Suspect: asymmetric top/bottom split logic in slot_host.dart _WorkspaceSlot, and closeEditor() not switching the active tab off editor.active. Details appended below.
## Repro
In the main column (the center "workspace" slot), the editor renders as a **top split** above the primary pane (Claude). Close the last editor panel/buffer in that top split (e.g. `:q`, `ctrl+w`, or closing the last editor tab).
- **Expected:** the top split collapses and the remaining primary pane takes the full column height.
- **Actual:** the top split does not collapse cleanly leftover/empty space or broken layout remains where the editor split was.
## Suspect code paths (root cause not yet confirmed)
- `lib/src/shell/slot_host.dart` `_WorkspaceSlot.build()` (~lines 147-206). The split is computed asymmetrically: the **top** region is dynamic `topTab = reveal ?? (editorOpen ? editorTab : null)` (~line 176), collapsing only when `topTab == null` (~line 178) while the **bottom** region is always `primaryPane = (claude ?? active)`. The top/bottom asymmetry is the prime suspect for why the top split specifically fails to collapse.
- `lib/kernel/src/panels/arrangement.dart` `closeEditor()` (~line 108) flips `editorOpen = false` but does not switch the workspace slot''s **active tab** away from `editor.active`. The editor tab stays registered, so a rebuild may still resolve `active`/`editorTab` to the editor and keep the split open.
- `lib/builtin/editor/src/extension.dart` (~lines 36-43) the `editor.active-changed` handler calls `ctx.arrangement.closeEditor()` when the last buffer closes (`id == null`). Confirm this fires and that the resulting rebuild actually reaches the collapse branch.
- Possible rebuild-notification gap: `_WorkspaceSlot`''s `ListenableBuilder` listens to `kernel.arrangement`; verify active-tab changes (`kernel.panels`) also drive the rebuild that recomputes `topTab`.
## Investigation / fix direction
1. Reproduce in the running app and pin down which of the above is the actual cause (asymmetric top/bottom split logic vs. active tab not switching away from the closed editor).
2. Make collapse symmetric and state-driven: when the editor top split has no remaining panel, the region must drop out so the primary pane fills the column independent of which tab is "active".
3. On editor close, ensure the workspace active tab is moved off `editor.active` (or that the split renderer ignores a closed editor tab regardless of active state).
## Tests
Existing coverage (`test/builtin/editor/editor_extension_test.dart`, `test/builtin/default_layout/widget_test.dart`, `test/app_test.dart`) asserts the `editorOpen` flag toggles but does **not** assert the rendered split actually collapses with no leftover region. Add a widget test that opens the editor top split, closes the last panel, and asserts the workspace renders only the primary pane (no empty split / drag handle remaining).', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 14:15:25.011', '2026-06-22 15:48:43.008', NULL, '7f47fde921ee006e517d13f6b21d3fa2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
+4
View File
@@ -25,6 +25,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
- **Default window opens larger (1600×900) on Linux.** 720p was short enough - **Default window opens larger (1600×900) on Linux.** 720p was short enough
that the welcome screen's version/theme footer overlapped the tips card; that the welcome screen's version/theme footer overlapped the tips card;
the taller default clears it, matching the macOS default. (T-477) the taller default clears it, matching the macOS default. (T-477)
- **Editor split collapses when the last buffer closes.** Closing the final
editor buffer left the top split orphaned over the Claude pane; the daemon
now emits the buffer-cleared event it was suppressing, so the split drops
out and the primary pane fills the column. (T-459)
## [2.8.0] — 2026-06-19 ## [2.8.0] — 2026-06-19
+6 -1
View File
@@ -180,8 +180,13 @@ class EditorRegistry {
if (buf == null) return; if (buf == null) return;
_pathToId.remove(buf.path); _pathToId.remove(buf.path);
if (_activeId == id) { if (_activeId == id) {
// Promote the next buffer, or clear to null when this was the last one.
// Emit active-changed in BOTH cases: a null id is the signal the editor
// split collapses on (T-459). Guarding the emit on `_activeId != null`
// suppressed exactly the last-buffer-closed event, leaving editorOpen
// stuck true and the top split orphaned over the primary pane.
_activeId = _buffers.values.isEmpty ? null : _buffers.values.first.id; _activeId = _buffers.values.isEmpty ? null : _buffers.values.first.id;
if (_activeId != null) _emitActive(); _emitActive();
} }
_emit('editor.closed', {'id': id, 'path': buf.path}); _emit('editor.closed', {'id': id, 'path': buf.path});
} }
+17
View File
@@ -89,6 +89,23 @@ void main() {
expect(sink.ofKind('editor.active-changed').length, greaterThanOrEqualTo(2)); expect(sink.ofKind('editor.active-changed').length, greaterThanOrEqualTo(2));
}); });
test('closing the last buffer clears active + emits active-changed with id=null (T-459)', () async {
final buf = await reg.open('README.md');
expect(reg.active, same(buf));
sink.events.clear();
reg.close(buf.id);
// No buffer left — active clears to null.
expect(reg.active, isNull);
// The collapse signal: active-changed must still fire, carrying a null id,
// so the editor split drops out. The bug (T-459) was this emit being
// guarded away on the last close, leaving editorOpen stuck true.
final activeChanged = sink.ofKind('editor.active-changed');
expect(activeChanged, hasLength(1));
expect(activeChanged.single.data['id'], isNull);
expect(activeChanged.single.data['path'], isNull);
expect(sink.ofKind('editor.closed'), hasLength(1));
});
test('opening a non-existent path creates an empty buffer', () async { test('opening a non-existent path creates an empty buffer', () async {
final buf = await reg.open('NEW.md'); final buf = await reg.open('NEW.md');
expect(buf.content, isEmpty); expect(buf.content, isEmpty);
+81
View File
@@ -0,0 +1,81 @@
/// T-459: the workspace editor top split must collapse cleanly when the
/// editor closes — no orphaned drag handle or empty region left over the
/// primary (Claude) pane.
///
/// This covers the render seam: given `arrangement.editorOpen`, the
/// `_WorkspaceSlot` either shows the split (editor body + drag handle over the
/// primary pane) or collapses to just the primary pane. The event seam that
/// flips `editorOpen` on the last buffer close is covered by the registry +
/// editor-extension tests.
library;
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/shell/slot_host.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async {
f = await KernelFixture.create();
// Stand-in workspace tabs: the real Claude pane / EditorView pull in PTY +
// tree-sitter FFI, which the split-collapse contract doesn't need.
f.services.panels.contribute(
TabContribution(
id: 'claude.primary',
slot: Slots.workspace,
title: 'Claude',
priority: 90,
build: (_) => const SizedBox(key: ValueKey('claude-body')),
),
);
f.services.panels.contribute(
TabContribution(
id: 'editor.active',
slot: Slots.workspace,
title: 'Editor',
priority: 80,
build: (_) => const SizedBox(key: ValueKey('editor-body')),
),
);
});
tearDown(() => f.dispose());
Finder dragHandle() => find.bySemanticsLabel('Editor split');
testWidgets('editor top split collapses to the primary pane when the editor closes (T-459)', (tester) async {
// Editor open + active: the split shows the editor body, the drag handle,
// and the primary pane below.
f.services.panels.activateTab(Slots.workspace, 'editor.active');
f.services.arrangement.openEditor();
await tester.pumpWidget(harness(f, const SlotHost(slot: Slots.workspace)));
await tester.pump();
expect(dragHandle(), findsOneWidget, reason: 'split should render its resize handle while the editor is open');
expect(find.byKey(const ValueKey('editor-body')), findsOneWidget);
expect(find.byKey(const ValueKey('claude-body')), findsOneWidget);
// Close the editor → the top split must drop out entirely, leaving only the
// primary pane (no leftover drag handle / empty region).
f.services.arrangement.closeEditor();
await tester.pump();
expect(dragHandle(), findsNothing, reason: 'collapsed split must not leave a drag handle behind');
expect(find.byKey(const ValueKey('editor-body')), findsNothing, reason: 'closed editor body must not linger');
expect(find.byKey(const ValueKey('claude-body')), findsOneWidget, reason: 'primary pane fills the column');
});
testWidgets('no split when the editor was never opened — primary pane only', (tester) async {
f.services.panels.activateTab(Slots.workspace, 'claude.primary');
await tester.pumpWidget(harness(f, const SlotHost(slot: Slots.workspace)));
await tester.pump();
expect(dragHandle(), findsNothing);
expect(find.byKey(const ValueKey('claude-body')), findsOneWidget);
});
}