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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user