diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index ee818595..36d011fa 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 9c56ea62..90ccbcfe 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 492c89e2..0f41028c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/lib/builtin/claude/src/claude_composer.dart b/lib/builtin/claude/src/claude_composer.dart index e9781afa..04edf132 100644 --- a/lib/builtin/claude/src/claude_composer.dart +++ b/lib/builtin/claude/src/claude_composer.dart @@ -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 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 createState() => _ClaudeComposerState(); } class _ClaudeComposerState extends State { 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 _attachments = []; // Slash typeahead state (T-152). @@ -126,6 +133,8 @@ class _ClaudeComposerState extends State { 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 { _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(); } diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 2cc984c3..3731d431 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -88,6 +88,10 @@ class _ClaudePaneState extends State { /// in the composer (T-163). Keyed by claude session id. final Map> _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 { if (!widget.isPrimary) unawaited(activeSessionOrchestrator?.close(_orchId)); _conversation = null; _session = null; + _composerFocus.dispose(); super.dispose(); } @@ -277,6 +282,15 @@ class _ClaudePaneState extends State { 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 { return Column( children: [ Expanded( - child: ConversationView( - controller: _conversation!, - hiddenToolUseIds: _session?.promptedToolUseIds ?? const {}, - toolUseOutcomes: _session?.toolUseOutcomes ?? const {}, - 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 {}, + toolUseOutcomes: _session?.toolUseOutcomes ?? const {}, + emptyState: ClaudeBanner( + role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}', + workspace: _repoRoot, + statusLine: _statusLine, + ), ), ), ), @@ -411,6 +433,7 @@ class _ClaudePaneState extends State { initialValue: _sessionId == null ? null : _drafts[_sessionId], onDraftChanged: _onDraftChanged, history: _sessionId == null ? const [] : (_history[_sessionId] ?? const []), + focusNode: _composerFocus, ), ), ], diff --git a/test/builtin/claude/claude_composer_test.dart b/test/builtin/claude/claude_composer_test.dart index b29e9eff..b893a274 100644 --- a/test/builtin/claude/claude_composer_test.dart +++ b/test/builtin/claude/claude_composer_test.dart @@ -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(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 = []; + 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());