From f6a3c527f427e48b95dca3d92b1d962ec48a7571 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 16 Jun 2026 16:20:05 +0200 Subject: [PATCH] chore(plan): file T-458 EOL/EOF fidelity, T-459 split-collapse bug Both filed under the T-276 UI epic. T-458: the editor records neither the original EOL style nor the trailing-newline state when it reads a file (registry.dart), and only normalizes on save when .editorconfig asks. Any layer that rewrites newlines therefore produces cross-platform commit churn with no .gitattributes backstop. Ticket captures the record-on-read / preserve-on-save / gitattributes plan. T-459: closing the last editor panel in the main column's top split does not collapse it. Suspect the asymmetric top/bottom split logic in slot_host.dart and closeEditor() not moving the active tab off editor.active. Root cause not yet confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 48 ++++++++++++++++++++++ .pql/changelog/ticket_idmap/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 50 +++++++++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index d3bbd912..c65f327c 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -5805,3 +5805,51 @@ 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 ('06FD15TY946689VZK3MNABSMDW', 'description', NULL, 'List installed extensions and surface each extension''s registered settings (ext..* — the extension-owned config pattern from ui-design theme.md); per-extension enable/disable + configure. Reuses the field renderer; extensions register their schema the same way core subsystems do.', NULL, '2026-06-16 13:20:47', '2026-06-16 13:20:47', '2026-06-16 13:20:47', NULL, '0f2073250cde4ea3213f33008aec0cba', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'description', NULL, 'Mirror lib/builtin/claude/src/meta_sidebar/config_tab.dart in the settings modal: SETTINGS controls (model / effort / permission mode / output style via SettingControlRow publishing the slash command on the live session, D-6 / T-414) plus carded config lists (Skills / Agents / Commands / Hooks / Permissions / MCP Servers) that open their .md (T-183). Reuse the data the sidebar already loads (ClaudeConfig). Wireframe: docs/design/wireframes/settings/settings-claude.png.', NULL, '2026-06-16 13:21:00', '2026-06-16 13:21:00', '2026-06-16 13:21:00', NULL, '1efc3eadc7b93d57c205deddab67bbb3', 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 ('06FB0TNQM7H8JWCP59WQVD0FW4', 'status', 'in_progress', 'done', NULL, '2026-06-16 13:21:11', '2026-06-16 13:21:11', '2026-06-16 13:21:11', NULL, '6410fd78c4919240bb5eaba9daa4f807', 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 ('06FD1HK7YKJTEK1WV4VHK0RT8R', 'description', 'Editor can introduce spurious EOL (LF/CRLF) and EOF (final-newline) changes on save, causing cross-platform commit churn. Harden by recording original EOL/EOF state on read and preserving it on save, plus a .gitattributes backstop. Details appended below.', 'Editor can introduce spurious EOL (LF/CRLF) and EOF (final-newline) changes on save, causing cross-platform commit churn. Harden by recording original EOL/EOF state on read and preserving it on save, plus a .gitattributes backstop. Details appended below. + +## Problem + +The editor can introduce spurious EOL (LF↔CRLF) and EOF (trailing-newline) changes on save, producing cross-platform commit churn — a "race" where Linux/macOS and Windows contributors repeatedly flip a file''s line endings or final newline back and forth in commits even when they made no real edit to those lines. + +## Current state / root cause + +- **Read path** — `lib/src/editor/registry.dart:50` loads via `File.readAsString()` and stores the buffer as a plain string in `EditorBuffer` (`lib/src/editor/buffer.dart`). It records **neither** the original EOL style (LF / CRLF / CR) **nor** whether the file ended with a trailing newline. +- **Save path** — `registry.dart:141` calls `EditorSettings.applyOnSave` (`lib/src/editor/editor_settings.dart:114`). That method only rewrites EOLs / final-newline when `.editorconfig` explicitly sets `end_of_line` / `insert_final_newline`; otherwise it returns the content unchanged. So byte-fidelity depends entirely on the buffer + text-editing widget round-trip faithfully preserving `\r\n` and the EOF state. Because the original state is never recorded on read, there is **no way to restore it** if any layer (the Flutter text controller / platform text input in `syntax_text_controller.dart` / `editor_controller.dart`) normalizes newlines. +- **No `.gitattributes` backstop** — the only entry is `.pql/changelog/*.sql merge=union`; there is no `* text=auto` / `eol` rule. Whatever the editor writes lands verbatim in commits with no normalization safety net. + +## Proposed hardening + +1. On read, detect and record on the buffer: (a) dominant EOL style, (b) whether the file has mixed EOLs, (c) presence/absence of a final newline. +2. On save, default to **preserving** the recorded EOL + EOF state when `.editorconfig` does not override (explicit `.editorconfig` settings still win). Mixed-EOL files preserved as-is unless explicit normalization is requested. +3. Audit the text-editing round-trip (`syntax_text_controller.dart`, `editor_controller.dart`) for silent `\r` dropping or final-newline mutation; if found, normalize at the buffer boundary on read and restore on save. +4. Add a repo `.gitattributes` (`* text=auto eol=lf` plus binary carve-outs) as defense-in-depth so commits do not churn even if the editor slips. +5. Tests: round-trip CRLF, LF, no-final-newline, and mixed-EOL fixtures through open → edit → save and assert byte-identical output absent explicit settings. Exercise the Linux/macOS/Windows save paths. + +## Acceptance + +Opening a file and saving it with no content edits produces a **byte-identical** file (no EOL/EOF diff) on Linux, macOS, and Windows regardless of the file''s original line endings — unless `.editorconfig` explicitly requests normalization.', NULL, '2026-06-16 14:12:09', '2026-06-16 14:12:09.834', '2026-06-16 14:12:09.834', NULL, 'add25fbe70d4ba9dbf3c4731bc00fc91', 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', 'description', '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.', '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).', NULL, '2026-06-16 14:15:54', '2026-06-16 14:15:54.344', '2026-06-16 14:15:54.344', NULL, '2dbe866fe833666db5afbc7430c0d9c8', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/ticket_idmap/2026-06.sql b/.pql/changelog/ticket_idmap/2026-06.sql index 1e7f1481..c69aa336 100644 --- a/.pql/changelog/ticket_idmap/2026-06.sql +++ b/.pql/changelog/ticket_idmap/2026-06.sql @@ -283,3 +283,5 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15S1A0WRTY9WWFG0R4AAZ4', 'T-455', '2026-06-16 13:20:25', '2026-06-16 13:20:25', NULL, '370e913c62eab739cf39bda08089e033', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15TY946689VZK3MNABSMDW', 'T-456', '2026-06-16 13:20:41', '2026-06-16 13:20:41', NULL, '6041202d66dd93ee3d22206ece3a7db7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15W7SBVAD3QR5NVE67PKWR', 'T-457', '2026-06-16 13:20:52', '2026-06-16 13:20:52', NULL, 'c9bef415fb7d9d03444fde53ecac3bb1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1HK7YKJTEK1WV4VHK0RT8R', 'T-458', '2026-06-16 14:12:04.212', '2026-06-16 14:12:04.212', NULL, '070f1def635ee013df639cb9fe81e357', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'T-459', '2026-06-16 14:15:25.011', '2026-06-16 14:15:25.011', NULL, '44d5db70ef13ead00c4dcc437c387cec', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index b09a2759..16ee96d5 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -7005,3 +7005,53 @@ the real .clide vs ~/.clide files), gear (collides with "settings"). Note that every Phosphor glyph already renders via PhosphorIconPainter(0xNNNN); only named consts need adding. Preview/picking is blocked on a native glyph card (see the new ticket) — goldens render the font as Ahem boxes.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-16 13:21:11', NULL, '1db1af71519321792537391a5f38b613', 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 ('06FD1HK7YKJTEK1WV4VHK0RT8R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor hardens EOL/EOF fidelity to stop cross-platform commit churn', 'Editor can introduce spurious EOL (LF/CRLF) and EOF (final-newline) changes on save, causing cross-platform commit churn. Harden by recording original EOL/EOF state on read and preserving it on save, plus a .gitattributes backstop. Details appended below.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 14:12:04.212', '2026-06-16 14:12:04.212', NULL, 'ba4504b18aed43d841f9eb57d5beae5a', 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 ('06FD1HK7YKJTEK1WV4VHK0RT8R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor hardens EOL/EOF fidelity to stop cross-platform commit churn', 'Editor can introduce spurious EOL (LF/CRLF) and EOF (final-newline) changes on save, causing cross-platform commit churn. Harden by recording original EOL/EOF state on read and preserving it on save, plus a .gitattributes backstop. Details appended below. + +## Problem + +The editor can introduce spurious EOL (LF↔CRLF) and EOF (trailing-newline) changes on save, producing cross-platform commit churn — a "race" where Linux/macOS and Windows contributors repeatedly flip a file''s line endings or final newline back and forth in commits even when they made no real edit to those lines. + +## Current state / root cause + +- **Read path** — `lib/src/editor/registry.dart:50` loads via `File.readAsString()` and stores the buffer as a plain string in `EditorBuffer` (`lib/src/editor/buffer.dart`). It records **neither** the original EOL style (LF / CRLF / CR) **nor** whether the file ended with a trailing newline. +- **Save path** — `registry.dart:141` calls `EditorSettings.applyOnSave` (`lib/src/editor/editor_settings.dart:114`). That method only rewrites EOLs / final-newline when `.editorconfig` explicitly sets `end_of_line` / `insert_final_newline`; otherwise it returns the content unchanged. So byte-fidelity depends entirely on the buffer + text-editing widget round-trip faithfully preserving `\r\n` and the EOF state. Because the original state is never recorded on read, there is **no way to restore it** if any layer (the Flutter text controller / platform text input in `syntax_text_controller.dart` / `editor_controller.dart`) normalizes newlines. +- **No `.gitattributes` backstop** — the only entry is `.pql/changelog/*.sql merge=union`; there is no `* text=auto` / `eol` rule. Whatever the editor writes lands verbatim in commits with no normalization safety net. + +## Proposed hardening + +1. On read, detect and record on the buffer: (a) dominant EOL style, (b) whether the file has mixed EOLs, (c) presence/absence of a final newline. +2. On save, default to **preserving** the recorded EOL + EOF state when `.editorconfig` does not override (explicit `.editorconfig` settings still win). Mixed-EOL files preserved as-is unless explicit normalization is requested. +3. Audit the text-editing round-trip (`syntax_text_controller.dart`, `editor_controller.dart`) for silent `\r` dropping or final-newline mutation; if found, normalize at the buffer boundary on read and restore on save. +4. Add a repo `.gitattributes` (`* text=auto eol=lf` plus binary carve-outs) as defense-in-depth so commits do not churn even if the editor slips. +5. Tests: round-trip CRLF, LF, no-final-newline, and mixed-EOL fixtures through open → edit → save and assert byte-identical output absent explicit settings. Exercise the Linux/macOS/Windows save paths. + +## Acceptance + +Opening a file and saving it with no content edits produces a **byte-identical** file (no EOL/EOF diff) on Linux, macOS, and Windows regardless of the file''s original line endings — unless `.editorconfig` explicitly requests normalization.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 14:12:04.212', '2026-06-16 14:12:09.834', NULL, 'e5f4a3e6dc5b9c8977ef36d0a17f2b9e', 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.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 14:15:25.011', '2026-06-16 14:15:25.011', NULL, 'ec93569d2ab51868382e04dc7711980b', 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).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 14:15:25.011', '2026-06-16 14:15:54.344', NULL, '50f4b7916e89ac7e0804ae6669940e90', 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);