panel-to-panel focus traversal via F6 / Shift+F6 (T-105)

Each SlotHost now owns a FocusScopeNode and registers it with
FocusTracker on mount. The render is wrapped in
FocusScope + FocusTraversalGroup so Tab stays within a panel and
slot-level focus is observable.

When a slot's scope gains focus, SlotHost pushes
(slot, activeContributionId) to FocusTracker — this collapses the
parallel-tracker model the consultant flagged. FocusTracker keeps
its setActive surface for explicit callers (palette, etc.) but
slot-scoped tab activation feeds it automatically.

Two new intents, two new bindings:
  FocusNextPanelIntent     → F6
  FocusPreviousPanelIntent → Shift+F6
(VS Code convention; preset YAML.)

The cycle skips slots without a registered scope, so a layout that
hides the context panel doesn't strand focus on a missing target.
Fewer than two registered → no-op.

SlotHost split into a stateful outer (scope + registry) and a
stateless `_SlotBody` (the existing slot-specific rendering),
keeping the build straightforward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:56:35 +02:00
co-authored by Claude Opus 4.7
parent 12e0509fa3
commit 7937da1734
8 changed files with 278 additions and 53 deletions
@@ -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;
+10
View File
@@ -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);
+4
View File
@@ -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,
+7
View File
@@ -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]
+116 -48
View File
@@ -119,6 +119,18 @@ class _RootShellState extends State<_RootShell> {
return null;
},
),
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
onInvoke: (_) {
widget.services.focus.focusNextSlot();
return null;
},
),
FocusPreviousPanelIntent: CallbackAction<FocusPreviousPanelIntent>(
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<SlotHost> createState() => _SlotHostState();
}
class _SlotHostState extends State<SlotHost> {
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<TabContribution> 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,
+59 -5
View File
@@ -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<SlotId, FocusScopeNode> _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<SlotId> 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]);
}
}
+15
View File
@@ -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<String, Intent Function()> 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(),
+65
View File
@@ -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();