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:
@@ -74,3 +74,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-228', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 09:54:14', '2026-06-03 09:54:14', '2026-06-03 09:54:14', NULL, 'e9fad0773bf9d930028187db8f219d9f', 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-163', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 10:02:02', '2026-06-03 10:02:02', '2026-06-03 10:02:02', NULL, 'a5f405a38487027074c841401ab2d45c', 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-228', 'status', 'in_progress', 'done', NULL, '2026-06-03 10:02:02', '2026-06-03 10:02:02', '2026-06-03 10:02:02', NULL, 'de2818775d8d462446c341b2e74aed2e', 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-163', 'status', 'in_progress', 'done', NULL, '2026-06-03 10:05:55', '2026-06-03 10:05:55', '2026-06-03 10:05:55', NULL, '36b6f761d7005750a192f2f4aa5e60b4', 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-227', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 10:05:55', '2026-06-03 10:05:55', '2026-06-03 10:05:55', NULL, 'ec0f5acabfaacca93fb1547bb2eb4280', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -151,3 +151,21 @@ Acceptance:
|
||||
4. Selection/caret position preserved, not just the string (nice-to-have).
|
||||
|
||||
Implementation sketch: store the draft (text + selection) on the per-session model/controller (kernel- or session-scoped), not in the composer widget''s State. Seed the TextEditingController from it on mount and write back on change, so a remount after the interaction zone closes restores it. Relates to T-227 (focusing the composer) and the interaction-zone behavior where prompts replace the composer rather than rendering inline.', 'done', 'high', NULL, NULL, NULL, '2026-06-03 09:49:35', '2026-06-03 10:02:02', NULL, 'bdd509507b199031cb2b368a2813245a', 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-163', 'task', 'T-229', 'Composer prompt history via Up/Down (Claude-CLI-style)', 'Arrow Up/Down in the Claude composer should scroll through previously-sent prompts, matching the Claude Code CLI UX:
|
||||
- Up: if the caret isn''t on the first visual line, move up one line (normal editing); once on the first line, Up steps to the previous prompt in history. Down mirrors it (move down within multiline; at the last line, step to the next/newer entry).
|
||||
- When entering history, stash the current draft as the newest entry so Down past the most recent restores exactly what the user was typing.
|
||||
- History is the sequence of prompts submitted from this composer (this session at least; persisting across restarts is a nice-to-have).
|
||||
- Plays well with the existing Enter-submit / Shift+Enter-newline and the slash typeahead (Up/Down already drive the typeahead when it''s open — history nav only applies when the typeahead is closed).
|
||||
|
||||
Implementation: a small history controller + caret-line detection in ClaudeComposer (EditableText); the Up/Down handling lives alongside the existing _onKey/Shortcuts. Acceptance: send a few prompts, Up walks back through them, Down returns through them to the restored draft; multiline prompts navigate line-by-line before crossing into history; widget tests for the bound conditions (first/last line) and draft stash/restore.', 'done', 'medium', NULL, NULL, NULL, '2026-05-24 09:42:44', '2026-06-03 10:05:55', NULL, '267d50ea429ba291e70a76201387bff7', 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-227', 'story', 'T-229', 'Click empty Claude-pane area to focus the composer', 'Clicking anywhere in the Claude pane that is NOT an interactive control (copy button, approve/reject, AskUserQuestion options, links, selectable transcript text, scrollbars, tabs) should move keyboard focus to the composer text field at the bottom. Today focusing the composer requires precisely clicking the field; a click on the conversation background does nothing, so the input is fiddly to focus.
|
||||
|
||||
Goal: make the composer easy to focus — a click on dead space in the pane lands the cursor in the input, like clicking an empty area of a chat app focuses its reply box.
|
||||
|
||||
Acceptance:
|
||||
1. A tap on non-interactive Claude-pane background focuses the composer input (cursor ready to type).
|
||||
2. Taps on actual controls keep their behavior (copy, approve/reject, AskUserQuestion choices, tab strip, links) — the focus-grab must not swallow or pre-empt their gestures.
|
||||
3. Text selection in the transcript still works: a click-drag to select must NOT steal focus to the composer (only a plain tap/click-release with no selection focuses it).
|
||||
4. Interaction-zone nuance (D-78): when a permission / AskUserQuestion prompt has replaced the composer in the interaction zone, a background click should focus that prompt''s primary control instead of the (absent) composer — or do nothing — never resurrect the composer.
|
||||
|
||||
Implementation sketch: wrap the pane body in a tap recognizer (behavior: translucent / deferToChild) that, on a tap that did not hit a child gesture and produced no text selection, requests focus on the composer FocusNode. Mind the SelectableRegion so selection drags aren''t hijacked.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-03 09:48:23', '2026-06-03 10:05:55', NULL, 'f8cede4c62e453b421cae5ba9a54af2b', 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);
|
||||
|
||||
@@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- Click empty Claude-pane area to focus the composer: a tap on conversation
|
||||
dead space lands the cursor in the input. Message controls and transcript
|
||||
text-selection are unaffected, and it stays inert while a prompt occupies the
|
||||
interaction zone. (T-227)
|
||||
- Claude composer prompt history (Claude-CLI-style): Up recalls previously-sent
|
||||
prompts once the caret reaches the first line, Down steps back to newer ones
|
||||
and restores your in-progress draft past the newest. Per session. (T-163)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -336,6 +336,39 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('ClaudeComposer external focus node (T-227)', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
testWidgets('uses a supplied focus node and focuses the input on request', (tester) async {
|
||||
final node = FocusNode();
|
||||
addTearDown(node.dispose);
|
||||
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, focusNode: node)));
|
||||
|
||||
expect(tester.widget<EditableText>(find.byType(EditableText)).focusNode, same(node));
|
||||
|
||||
node.requestFocus(); // what the pane does on a background tap
|
||||
await tester.pump();
|
||||
expect(node.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('key handling still works through the supplied node', (tester) async {
|
||||
final submitted = <String>[];
|
||||
final node = FocusNode();
|
||||
addTearDown(node.dispose);
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ClaudeComposer(onSubmit: submitted.add, focusNode: node),
|
||||
));
|
||||
await tester.enterText(find.byType(EditableText), 'via external node');
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(submitted, ['via external node']);
|
||||
});
|
||||
});
|
||||
|
||||
group('ClaudeComposer prompt history (T-163)', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
|
||||
Reference in New Issue
Block a user