focus the Claude composer on a background tap

T-227. A tap on empty conversation area now lands the cursor in the
composer. The conversation area is wrapped in a translucent
GestureDetector whose onTap focuses a pane-owned composer FocusNode, so
message links, copy buttons, and the SelectableRegion's selection drags
keep winning their own gestures — only an unclaimed tap reaches us. It's
a no-op while a prompt holds the interaction zone (D-78), so a tap never
pulls focus over an open prompt.

The composer learned to accept an external focus node (the pane owns it,
so it survives composer remounts) and attaches its key handling to
whichever node it's given.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 12:09:17 +02:00
co-authored by Claude Opus 4.8
parent ab5d63debd
commit 4508be76ac
6 changed files with 103 additions and 10 deletions
+15 -2
View File
@@ -51,6 +51,7 @@ class ClaudeComposer extends StatefulWidget {
this.initialValue,
this.onDraftChanged,
this.history = const [],
this.focusNode,
});
/// Called with the composed message (typed text plus attachment `@path`
@@ -92,13 +93,19 @@ class ClaudeComposer extends StatefulWidget {
/// the pane (per session); the composer only reads it.
final List<String> history;
/// Optional externally-owned focus node, so the pane can focus the
/// composer (e.g. on a background tap, T-227). When provided the owner
/// disposes it; otherwise the composer creates and disposes its own.
final FocusNode? focusNode;
@override
State<ClaudeComposer> createState() => _ClaudeComposerState();
}
class _ClaudeComposerState extends State<ClaudeComposer> {
final TextEditingController _controller = TextEditingController();
late final FocusNode _focus = FocusNode(onKeyEvent: _onKey);
late final bool _ownsFocus = widget.focusNode == null;
late final FocusNode _focus = widget.focusNode ?? FocusNode();
final List<ComposerAttachment> _attachments = [];
// Slash typeahead state (T-152).
@@ -126,6 +133,8 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
if (seed != null && seed.text.isNotEmpty) {
_controller.value = seed;
}
// Drive key handling whether the node is ours or the pane's (T-227).
_focus.onKeyEvent = _onKey;
_controller.addListener(_onTextChanged);
_focus.addListener(_onFocusChanged);
}
@@ -136,7 +145,11 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
_controller.removeListener(_onTextChanged);
_focus.removeListener(_onFocusChanged);
_controller.dispose();
_focus.dispose();
if (_ownsFocus) {
_focus.dispose();
} else {
_focus.onKeyEvent = null; // detach our handler from the pane-owned node
}
super.dispose();
}
+31 -8
View File
@@ -88,6 +88,10 @@ class _ClaudePaneState extends State<ClaudePane> {
/// in the composer (T-163). Keyed by claude session id.
final Map<String, List<String>> _history = {};
/// Focus node for the composer, owned here so a tap on empty pane area can
/// focus the input (T-227). Survives composer remounts (prompt swaps).
final FocusNode _composerFocus = FocusNode(debugLabel: 'claude-composer');
/// This pane's stable key in the session orchestrator (T-169).
String get _orchId => widget.isPrimary ? 'primary' : 'secondary-${widget.secondaryIndex}';
@@ -144,6 +148,7 @@ class _ClaudePaneState extends State<ClaudePane> {
if (!widget.isPrimary) unawaited(activeSessionOrchestrator?.close(_orchId));
_conversation = null;
_session = null;
_composerFocus.dispose();
super.dispose();
}
@@ -277,6 +282,15 @@ class _ClaudePaneState extends State<ClaudePane> {
if (list.isEmpty || list.last != text) list.add(text);
}
/// Focus the composer when the user taps empty conversation area (T-227).
/// No-op while a prompt occupies the interaction zone (D-78) — a
/// background tap must never pull focus from (or resurrect) the composer
/// over an open prompt.
void _focusComposerOnTap() {
if (_session?.pendingPrompt != null) return;
_composerFocus.requestFocus();
}
/// Persist (or clear) the composer draft for the active session (T-228).
/// The composer reports an empty value on submit/clear, which drops the
/// entry so a sent message doesn't reappear.
@@ -379,14 +393,22 @@ class _ClaudePaneState extends State<ClaudePane> {
return Column(
children: [
Expanded(
child: ConversationView(
controller: _conversation!,
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
emptyState: ClaudeBanner(
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
workspace: _repoRoot,
statusLine: _statusLine,
// A tap on empty conversation area focuses the composer
// (T-227). Translucent so message links, copy buttons, and
// the SelectableRegion's selection drags win their own
// gestures; only an unclaimed tap reaches us.
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _focusComposerOnTap,
child: ConversationView(
controller: _conversation!,
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
emptyState: ClaudeBanner(
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
workspace: _repoRoot,
statusLine: _statusLine,
),
),
),
),
@@ -411,6 +433,7 @@ class _ClaudePaneState extends State<ClaudePane> {
initialValue: _sessionId == null ? null : _drafts[_sessionId],
onDraftChanged: _onDraftChanged,
history: _sessionId == null ? const [] : (_history[_sessionId] ?? const []),
focusNode: _composerFocus,
),
),
],