From debfcaeaa96fa3d8aa2fa566baf20bb4193d4ae8 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 23 Oct 2026 17:33:39 +0100 Subject: [PATCH] Keep the keyboard up while the composer sheet changes above its field EDGE numbers unkeyed nodes in the order they're drawn, and iOS and Android treat a new id as a new view. Typing the first character turned Commit on, which wraps its circle in a pressable and adds a node above the field. Every id after it moved, so the field was rebuilt and the keyboard went down. The issue badge and the error and refresh banners above the sheet did the same. Keying the sheet and its field keeps the field's id stable. Tests check the ids from the root to the field stay put in each case, and that the Reply bar and the Remotes composer (already safe, since bottom bars are hoisted ahead of the page) stay that way. Co-Authored-By: Claude Opus 5.5 --- .../native/partials/commit-sheet.blade.php | 7 +++++- tests/Feature/Screens/CommitDetailTest.php | 20 +++++++++++++++++ tests/Feature/Screens/ComposeSheetTest.php | 20 +++++++++++++++++ tests/Feature/Screens/ConversationTest.php | 14 ++++++++++++ tests/Pest.php | 22 +++++++++++++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) diff --git a/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php index f656da4..a9238ed 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -29,8 +29,12 @@ It's full height for the forms, and short for the receipts. It's always on the screen, shown or not, so iOS can slide it away and say when it's gone. + + The sheet and its field are keyed, so nothing drawn above the field (Commit turning on, the issue badge, a banner + behind the sheet) gives it a new id. iOS and Android would build a new field for a new id, and the keyboard would + go down with the old one. --}} - + @if ($sheetCommit || in_array($sheetView, ['compose', 'issued'], true)) @if (in_array($sheetView, ['quote', 'compose', 'reply'], true)) @php($quoting = $sheetView === 'quote') @@ -66,6 +70,7 @@ @include('native.partials.composer-preview') @else and(nodeByRef($screen, 'thread-scroll')['id'])->not->toBe($before); }); +it('keeps the reply field the same view while the thread above it changes, so the keyboard stays up', function () { + fakeThread(['replies_count' => 1], replies: [replyJson(['id' => 'r1', 'parent_id' => 'c2', 'body' => 'First reply', 'author' => userJson(['username' => 'grace', 'name' => 'Grace'])])]); + + $screen = Native::visit('/log/commits/c2')->tap('earlier-commits')->input('reply-input', 'Nice'); + $field = idsDownTo($screen->tree(), 'reply-input'); + + // Tapping into the bar, which folds the earlier commits away above it. + $screen->pressUp('reply-field')->assertDontSee('The parent commit'); + expect(idsDownTo($screen->tree(), 'reply-input'))->toBe($field); + + // More of the thread drawn above it. + $screen->set('repliesShown', true)->assertSee('First reply'); + expect(idsDownTo($screen->tree(), 'reply-input'))->toBe($field); + + // A refresh failing, which puts its banner at the top of the screen. + $screen->set('refreshFailed', true); + expect(nodeByRef($screen, 'refresh-failed'))->not->toBeEmpty() + ->and(idsDownTo($screen->tree(), 'reply-input'))->toBe($field); +}); + it('runs the rail from the folded ancestors into the commit, which shows its avatar', function () { $ada = userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada', 'lane' => 1]); fakeApi([ diff --git a/tests/Feature/Screens/ComposeSheetTest.php b/tests/Feature/Screens/ComposeSheetTest.php index 9638209..319ca25 100644 --- a/tests/Feature/Screens/ComposeSheetTest.php +++ b/tests/Feature/Screens/ComposeSheetTest.php @@ -214,6 +214,26 @@ function sheetCommitDisabled(TestableComponent $screen): bool ->and(sheetButtonCircle($android, 'sheet-cancel')['props'])->not->toHaveKey('glass'); }); +it('keeps the field the same view while what is drawn above it changes, so the keyboard stays up', function () { + $screen = composeSheetOnLog(); + $field = idsDownTo($screen->tree(), 'sheet-body'); + + // Commit turning on, which wraps its circle in a pressable. + $screen->input('sheet-body', 'Shipped'); + expect(sheetCommitDisabled($screen))->toBeFalse() + ->and(idsDownTo($screen->tree(), 'sheet-body'))->toBe($field); + + // The issue badge over the field. + $screen->input('sheet-body', '/feature Dark mode'); + expect(nodeByRef($screen, 'sheet-open-issue'))->not->toBeEmpty() + ->and(idsDownTo($screen->tree(), 'sheet-body'))->toBe($field); + + // A refresh failing behind the sheet, which puts its banner above it. + $screen->set('refreshFailed', true); + expect(nodeByRef($screen, 'refresh-failed'))->not->toBeEmpty() + ->and(idsDownTo($screen->tree(), 'sheet-body'))->toBe($field); +}); + it('only commits from a sheet that has a composer in it', function () { fakeApi(['GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])])])]]); diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index c2264bf..7864d17 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -106,6 +106,20 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = ->and(sentTo('PUT', 'remotes/@ada/read'))->toBe(['{"through":2}']); }); +it('keeps the composer the same view while messages arrive above it, so the keyboard stays up', function () { + serveRemoteThread( + threadJson([remoteMessageJson(['id' => 1, 'body' => 'first'])], ['read_through_id' => 1]), + threadJson([remoteMessageJson(['id' => 2, 'body' => 'second'])]), + ); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->input('message-input', 'on my way'); + $field = idsDownTo($screen->tree(), 'message-input'); + + finishRefreshes($screen->call('poll'))->assertSee('second'); + + expect(idsDownTo($screen->tree(), 'message-input'))->toBe($field); +}); + it('waits longer between polls once nobody has written or typed for a minute', function () { serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); diff --git a/tests/Pest.php b/tests/Pest.php index 9ea9bd6..5d3a50e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -697,6 +697,28 @@ function findNodes(array $node, callable $matches): array return $found; } +/** + * The ids of every node from the root down to the one with the given ref. iOS and Android know a view by its id at + * each level, so a new id anywhere on the way down builds a new field, and the keyboard goes with the old one. + * + * @param array $node + * @return list|null + */ +function idsDownTo(array $node, string $ref): ?array +{ + if (($node['ref'] ?? null) === $ref) { + return [$node['id']]; + } + + foreach ($node['children'] ?? [] as $child) { + if (($ids = idsDownTo($child, $ref)) !== null) { + return [$node['id'], ...$ids]; + } + } + + return null; +} + /** * The rendered node with the given ref. *