diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 8bcf3cbe..e4f4902e 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -1799,3 +1799,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-117', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:39:40', '2026-05-17 19:39:40', '2026-05-17 19:39:40', NULL, '75b2fd14ceb7daf28645ccf84cb041f1', 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-100', 'status', 'in_progress', 'in_progress', NULL, '2026-05-17 19:40:44', '2026-05-17 19:40:44', '2026-05-17 19:40:44', NULL, '65600862f5787501dcd057756cdb4313', 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-100', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:47:50', '2026-05-17 19:47:50', '2026-05-17 19:47:50', NULL, '7d3c0ad4c47d3271ac35c5159e6e9d0e', 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-105', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 19:52:39', '2026-05-17 19:52:39', '2026-05-17 19:52:39', NULL, '129acb5d6b1882cb6ed4c9be46fd3e81', 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-105', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:56:28', '2026-05-17 19:56:28', '2026-05-17 19:56:28', NULL, '62dfdadc31b2c231afd837fb46f0a114', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 3495679e..1c224ffe 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -1771,3 +1771,13 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, 3. Extend the a11y test layer to assert operability (Tab + Enter actually invokes), not just Semantics presence. Source: consultants.md "UX — Findings — [Critical]".', 'done', 'high', NULL, NULL, NULL, '2026-05-17 18:47:23', '2026-05-17 19:47:50', NULL, 'f6f0c34a700b133bc84c6d8d7ea450a3', 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-105', 'story', 'T-97', 'establish focus-traversal model; integrate FocusTracker with Flutter focus', 'No `FocusScope`/`FocusTraversalGroup` wiring across panels. `FocusTracker` (`lib/kernel/src/focus.dart`) tracks an active contribution id for the `clide active` CLI but doesn''t plug into Flutter''s focus system — it parallels it. Tab order across sidebar → workspace → context is undefined. + +**Fix:** +1. Per-slot `FocusTraversalGroup` so each panel manages its own traversal. +2. A documented top-level keybinding (e.g. `Ctrl+Shift+Tab`-style) that moves focus to the next panel. +3. Integrate `FocusTracker` with Flutter focus rather than maintaining a parallel registry. + +Depends on T-100 (ClideTappable accepting focus). + +Source: consultants.md "UX — Findings — [Major] No focus-traversal wiring".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:47:47', '2026-05-17 19:56:28', NULL, '0715d1ded0d12083622f8a46d570f4d9', 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 30caf1eb..df02463a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. - Keyboard operability — `ClideTappable` is now Tab-focusable with a focus ring and Enter/Space activation; `ClidePalette` adds arrow nav, Escape dismiss, and selection highlight (T-100). +- Panel-to-panel focus traversal — each `SlotHost` wraps in a + `FocusScope` + `FocusTraversalGroup`; `F6` / `Shift+F6` cycle + sidebar → workspace → context. `FocusTracker` integrates with + Flutter focus rather than paralleling it (T-105). - Test sweep — `keybindings`, `toolchain_paths`, and several `widgets/src/` primitives (tooltip, palette, multitab, markdown). - `tree_sitter_service` sweep — fake-FFI + real-library smoke, diff --git a/assets/keymaps/default.yaml b/assets/keymaps/default.yaml index d408d2c7..172fb5b3 100644 --- a/assets/keymaps/default.yaml +++ b/assets/keymaps/default.yaml @@ -30,6 +30,13 @@ bindings: - intent: focus.previous keys: shift+tab + # Panel-to-panel cycling (sidebar → workspace → context). + # VS Code convention. + - intent: focus.nextPanel + keys: f6 + - intent: focus.previousPanel + keys: shift+f6 + # -- Command palette -------------------------------------------------- - intent: palette.open keys: [ctrl+shift+p, meta+shift+p] diff --git a/lib/app.dart b/lib/app.dart index fd0f14e5..7772f559 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -119,6 +119,18 @@ class _RootShellState extends State<_RootShell> { return null; }, ), + FocusNextPanelIntent: CallbackAction( + onInvoke: (_) { + widget.services.focus.focusNextSlot(); + return null; + }, + ), + FocusPreviousPanelIntent: CallbackAction( + onInvoke: (_) { + widget.services.focus.focusPreviousSlot(); + return null; + }, + ), }, child: KeyboardListener( focusNode: _keyFocus, @@ -736,66 +748,122 @@ class _NotARepoDialog extends StatelessWidget { } } -class SlotHost extends StatelessWidget { +class SlotHost extends StatefulWidget { const SlotHost({super.key, required this.slot}); final SlotId slot; + @override + State createState() => _SlotHostState(); +} + +class _SlotHostState extends State { + late final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'SlotScope:${widget.slot.value}'); + FocusTracker? _tracker; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final kernel = ClideKernel.of(context); + if (!identical(_tracker, kernel.focus)) { + _tracker?.unregisterSlotScope(widget.slot, _scope); + _tracker = kernel.focus; + _tracker!.registerSlotScope(widget.slot, _scope); + } + } + + @override + void dispose() { + _tracker?.unregisterSlotScope(widget.slot, _scope); + _scope.dispose(); + super.dispose(); + } + + void _onFocusChange(bool hasFocus) { + if (!hasFocus || _tracker == null) return; + final kernel = ClideKernel.of(context); + final activeId = kernel.panels.activeTabIn(widget.slot); + if (activeId != null) { + _tracker!.setActive(slot: widget.slot, contributionId: activeId); + } + } + @override Widget build(BuildContext context) { final kernel = ClideKernel.of(context); final tokens = ClideTheme.of(context).surface; - return ListenableBuilder( - listenable: Listenable.merge([kernel.panels, kernel.i18n]), - builder: (ctx, _) { - final tabs = kernel.panels.tabsFor(slot); - if (tabs.isEmpty) { - return Container(color: tokens.panelBackground); - } - final activeId = kernel.panels.activeTabIn(slot) ?? tabs.first.id; - final active = tabs.firstWhere( - (t) => t.id == activeId, - orElse: () => tabs.first, - ); + return FocusScope( + node: _scope, + onFocusChange: _onFocusChange, + child: FocusTraversalGroup( + child: ListenableBuilder( + listenable: Listenable.merge([kernel.panels, kernel.i18n]), + builder: (ctx, _) { + final tabs = kernel.panels.tabsFor(widget.slot); + if (tabs.isEmpty) { + return Container(color: tokens.panelBackground); + } + final activeId = kernel.panels.activeTabIn(widget.slot) ?? tabs.first.id; + final active = tabs.firstWhere( + (t) => t.id == activeId, + orElse: () => tabs.first, + ); + return _SlotBody(slot: widget.slot, tabs: tabs, active: active, activeId: activeId); + }, + ), + ), + ); + } +} - if (slot == Slots.sidebar) { - return _SidebarSlot( - tabs: tabs, - active: active, - activeId: activeId, - onSelect: (id) => kernel.panels.activateTab(slot, id), - ); - } +class _SlotBody extends StatelessWidget { + const _SlotBody({required this.slot, required this.tabs, required this.active, required this.activeId}); + final SlotId slot; + final List tabs; + final TabContribution active; + final String activeId; - if (slot == Slots.contextPanel) { - return _ContextSlot( - tabs: tabs, - active: active, - activeId: activeId, - onSelect: (id) => kernel.panels.activateTab(slot, id), - ); - } + @override + Widget build(BuildContext context) { + final kernel = ClideKernel.of(context); + final tokens = ClideTheme.of(context).surface; - if (slot == Slots.workspace) { - return _WorkspaceSlot(tabs: tabs, active: active); - } + if (slot == Slots.sidebar) { + return _SidebarSlot( + tabs: tabs, + active: active, + activeId: activeId, + onSelect: (id) => kernel.panels.activateTab(slot, id), + ); + } - return Container( - color: tokens.panelBackground, - child: Column( - children: [ - ClideTabBar( - items: [ - for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(ctx, t)), - ], - activeId: active.id, - onSelect: (id) => kernel.panels.activateTab(slot, id), - ), - ClideDivider(), - Expanded(child: active.build(ctx)), + if (slot == Slots.contextPanel) { + return _ContextSlot( + tabs: tabs, + active: active, + activeId: activeId, + onSelect: (id) => kernel.panels.activateTab(slot, id), + ); + } + + if (slot == Slots.workspace) { + return _WorkspaceSlot(tabs: tabs, active: active); + } + + return Container( + color: tokens.panelBackground, + child: Column( + children: [ + ClideTabBar( + items: [ + for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(context, t)), ], + activeId: active.id, + onSelect: (id) => kernel.panels.activateTab(slot, id), ), - ); - }, + ClideDivider(), + Expanded(child: active.build(context)), + ], + ), ); } @@ -975,7 +1043,7 @@ class _BottomRail extends StatelessWidget { ClideIconRailItem( id: t.id, icon: _iconFor(slot, t), - tooltip: SlotHost._resolveTitle(ctx, t), + tooltip: _SlotBody._resolveTitle(ctx, t), ), ], activeId: activeId, diff --git a/lib/kernel/src/focus.dart b/lib/kernel/src/focus.dart index 821a296c..13367548 100644 --- a/lib/kernel/src/focus.dart +++ b/lib/kernel/src/focus.dart @@ -1,13 +1,27 @@ import 'package:clide/kernel/src/panels/slot_id.dart'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; -/// Tracks the currently focused contribution (tab id + slot). Backs -/// `clide active`; extensions that need "which tab does the user care -/// about right now?" read from here instead of poking Flutter's -/// FocusScope directly. +/// Tracks the currently focused contribution (tab id + slot) and +/// hosts a registry of per-slot [FocusScopeNode]s for panel-to-panel +/// focus traversal. +/// +/// Backs `clide active` (extensions read activeSlot / activeContributionId) +/// and the `FocusNextPanelIntent` / `FocusPreviousPanelIntent` actions +/// the keymap dispatches on F6 / Shift+F6. class FocusTracker extends ChangeNotifier { SlotId? _slot; String? _contributionId; + final Map _scopes = {}; + + /// Static order panels cycle through. Matches the visual left-to-right + /// of the three-column layout (sidebar → workspace → context); the + /// statusbar / toolbar aren't included because they don't host + /// keyboard-active content. + static const List traversalOrder = [ + Slots.sidebar, + Slots.workspace, + Slots.contextPanel, + ]; SlotId? get activeSlot => _slot; String? get activeContributionId => _contributionId; @@ -25,4 +39,44 @@ class FocusTracker extends ChangeNotifier { _contributionId = null; notifyListeners(); } + + /// SlotHost calls this in initState. Replaces any prior registration + /// for the same slot (handles hot-reload + slot rebuild). + void registerSlotScope(SlotId slot, FocusScopeNode scope) { + _scopes[slot] = scope; + } + + /// SlotHost calls this in dispose. No-op if a newer scope already + /// took the slot. + void unregisterSlotScope(SlotId slot, FocusScopeNode scope) { + if (identical(_scopes[slot], scope)) { + _scopes.remove(slot); + } + } + + /// Request focus on [slot]'s registered scope. No-op when the slot + /// has no registered scope (panel not mounted, layout doesn't + /// include it, etc.). + void focusSlot(SlotId slot) { + final scope = _scopes[slot]; + if (scope == null) return; + scope.requestFocus(); + } + + /// Move focus to the next slot in [traversalOrder], wrapping at the + /// end. No-op when fewer than two slots are registered. + void focusNextSlot() => _cycleSlot(1); + + /// Move focus to the previous slot, wrapping at the start. + void focusPreviousSlot() => _cycleSlot(-1); + + void _cycleSlot(int delta) { + final registered = traversalOrder.where(_scopes.containsKey).toList(); + if (registered.length < 2) return; + final from = registered.indexOf(_slot ?? registered.first); + final start = from < 0 ? 0 : from; + final next = (start + delta) % registered.length; + final wrapped = next < 0 ? next + registered.length : next; + focusSlot(registered[wrapped]); + } } diff --git a/lib/kernel/src/keymap/intents.dart b/lib/kernel/src/keymap/intents.dart index 4ea92d8f..6a834bbc 100644 --- a/lib/kernel/src/keymap/intents.dart +++ b/lib/kernel/src/keymap/intents.dart @@ -19,6 +19,19 @@ library; import 'package:flutter/widgets.dart'; +// -- Panel-to-panel focus traversal ----------------------------------------- + +/// Move focus to the next panel in `FocusTracker.traversalOrder` +/// (sidebar → workspace → context). Bound to F6 by default. +class FocusNextPanelIntent extends Intent { + const FocusNextPanelIntent(); +} + +/// Move focus to the previous panel. Bound to Shift+F6 by default. +class FocusPreviousPanelIntent extends Intent { + const FocusPreviousPanelIntent(); +} + // -- Command palette -------------------------------------------------------- /// Open the command palette. @@ -76,6 +89,8 @@ class InvokeCommandIntent extends Intent { final Map builtinIntents = { 'activate': () => const ActivateIntent(), 'dismiss': () => const DismissIntent(), + 'focus.nextPanel': () => const FocusNextPanelIntent(), + 'focus.previousPanel': () => const FocusPreviousPanelIntent(), 'palette.open': () => const PaletteOpenIntent(), 'palette.selectNext': () => const PaletteSelectNextIntent(), 'palette.selectPrevious': () => const PaletteSelectPreviousIntent(), diff --git a/test/kernel/src/services_stubs_test.dart b/test/kernel/src/services_stubs_test.dart index 6a929cd1..6090439f 100644 --- a/test/kernel/src/services_stubs_test.dart +++ b/test/kernel/src/services_stubs_test.dart @@ -6,6 +6,7 @@ library; import 'package:clide/extension/src/contribution.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show FocusScopeNode; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -114,6 +115,70 @@ void main() { }); }); + group('FocusTracker — slot scope registry (T-105)', () { + test('focusSlot is a no-op when no scope is registered', () { + FocusTracker().focusSlot(Slots.sidebar); // doesn't throw + }); + + test('focusSlot requests focus on the registered scope', () { + final tracker = FocusTracker(); + final scope = FocusScopeNode(); + addTearDown(scope.dispose); + tracker.registerSlotScope(Slots.workspace, scope); + tracker.focusSlot(Slots.workspace); + // FocusScopeNode.requestFocus only flips hasPrimaryFocus when + // attached to a tree; we assert the no-throw + the registration + // round-trip rather than primary focus state (covered by the + // widget test). + tracker.unregisterSlotScope(Slots.workspace, scope); + tracker.focusSlot(Slots.workspace); // now a no-op + }); + + test('unregister skips when a newer scope took the slot', () { + final tracker = FocusTracker(); + final older = FocusScopeNode(); + final newer = FocusScopeNode(); + addTearDown(() { + older.dispose(); + newer.dispose(); + }); + tracker.registerSlotScope(Slots.workspace, older); + tracker.registerSlotScope(Slots.workspace, newer); + // Older calls unregister but the slot now holds `newer` — must + // not remove it. + tracker.unregisterSlotScope(Slots.workspace, older); + tracker.focusSlot(Slots.workspace); // still wired to `newer` + }); + + test('focusNextSlot / focusPreviousSlot cycle through registered slots only', () { + final tracker = FocusTracker(); + final sidebar = FocusScopeNode(debugLabel: 'sidebar'); + final workspace = FocusScopeNode(debugLabel: 'workspace'); + addTearDown(() { + sidebar.dispose(); + workspace.dispose(); + }); + tracker.registerSlotScope(Slots.sidebar, sidebar); + tracker.registerSlotScope(Slots.workspace, workspace); + // contextPanel intentionally NOT registered — should be skipped. + + tracker.setActive(slot: Slots.sidebar, contributionId: 'a'); + // The cycle is silent (no notify on focus call alone), but we can + // assert it doesn't throw and produces a deterministic shape. + tracker.focusNextSlot(); + tracker.focusPreviousSlot(); + }); + + test('cycle no-ops when fewer than two slots are registered', () { + final tracker = FocusTracker(); + final scope = FocusScopeNode(); + addTearDown(scope.dispose); + tracker.registerSlotScope(Slots.workspace, scope); + tracker.focusNextSlot(); // no-op, no throw + tracker.focusPreviousSlot(); + }); + }); + group('NetworkStatus', () { test('default state is online', () { final n = NetworkStatus();