diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a40b0ad..6f7f0e2 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -39,7 +39,7 @@ jobs: ANDROID_AVD_HOME: ${{ runner.temp }}/threadline-avd run: | mkdir -p "${ANDROID_AVD_HOME}" - echo "no" | avdmanager create avd --force --name threadline-ci --package "system-images;android-35;google_apis;x86_64" + echo "no" | avdmanager create avd --force --name threadline-ci --package "system-images;android-35;google_apis;x86_64" --device pixel_6 "${ANDROID_HOME}/emulator/emulator" -list-avds | grep -Fx threadline-ci - name: Start emulator shell: bash diff --git a/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt b/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt index 932e52c..74941e9 100644 --- a/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt +++ b/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt @@ -1,25 +1,30 @@ package dev.threadline import android.os.SystemClock +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.StateRestorationTester -import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.isPopup import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onAllNodesWithText @@ -31,6 +36,9 @@ import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.Density +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import dev.threadline.core.shell.ActiveCommand import dev.threadline.core.shell.CommandExecutionMode import dev.threadline.core.shell.CommandId @@ -53,7 +61,7 @@ import org.junit.Test class TranscriptScreenTest { @get:Rule - val composeRule = createComposeRule() + val composeRule = createAndroidComposeRule() @Test fun composerSubmitsExactMultilineCommandAndClearsAfterAcceptance() { @@ -981,6 +989,87 @@ class TranscriptScreenTest { } } + @Test + fun transcriptKeepsLatestTurnVisibleWhenImeInsetsArrive() { + composeRule.runOnIdle { + composeRule.activity.enableEdgeToEdge() + } + lateinit var composeView: View + val latestTurnId = "ime-command" + val transcript = mutableStateOf( + CommandTranscriptState( + turns = (0 until 6).map { index -> + turn( + id = "command-$index", + command = "printf command-$index", + status = CommandStatus.SUCCEEDED, + output = "result-$index", + ) + }, + ), + ) + composeRule.setContent { + MaterialTheme { + ConnectedSessionScreen( + displayName = "Keyboard viewport test", + structuredShell = StructuredShellState.Ready("/tmp"), + transcript = transcript.value, + onSubmit = { command -> + transcript.value = CommandTranscriptState( + turns = transcript.value.turns + turn( + id = latestTurnId, + command = command, + status = CommandStatus.SUCCEEDED, + output = "latest result", + ), + ) + CommandSubmissionResult.Accepted(CommandId(latestTurnId)) + }, + onControlC = {}, + onDisconnect = {}, + ) + } + } + composeRule.onNodeWithTag(TranscriptTags.output("command-5")) + .assertIsDisplayed() + composeRule.onNodeWithTag(TranscriptTags.COMPOSER) + .performClick() + .assertIsFocused() + composeRule.runOnIdle { + composeView = composeRule.activity + .findViewById(android.R.id.content) + .getChildAt(0) + WindowInsetsControllerCompat( + composeRule.activity.window, + composeView, + ).show(WindowInsetsCompat.Type.ime()) + } + composeRule.waitUntil(timeoutMillis = 10_000) { + ViewCompat.getRootWindowInsets(composeView) + ?.isVisible(WindowInsetsCompat.Type.ime()) == true + } + val composerBottom = composeRule + .onNodeWithTag(TranscriptTags.COMPOSER) + .fetchSemanticsNode() + .boundsInRoot + .bottom + val visibleBottom = ViewCompat.getRootWindowInsets(composeView) + ?.getInsets(WindowInsetsCompat.Type.ime()) + ?.let { composeView.height - it.bottom } + assertTrue( + "Composer bottom $composerBottom exceeded IME top $visibleBottom", + visibleBottom != null && composerBottom <= visibleBottom, + ) + composeRule.onNodeWithTag(TranscriptTags.output("command-5")) + .assertIsDisplayed() + composeRule.onNodeWithTag(TranscriptTags.COMPOSER) + .performTextInput("printf latest") + composeRule.onNodeWithTag(TranscriptTags.SEND).performClick() + + composeRule.onNodeWithTag(TranscriptTags.output(latestTurnId)) + .assertIsDisplayed() + } + private fun runningShell() = StructuredShellState.Running( activeCommand = ActiveCommand( id = CommandId("command-42"), diff --git a/app/src/main/java/dev/threadline/TranscriptScreen.kt b/app/src/main/java/dev/threadline/TranscriptScreen.kt index e75f42f..9de7f57 100644 --- a/app/src/main/java/dev/threadline/TranscriptScreen.kt +++ b/app/src/main/java/dev/threadline/TranscriptScreen.kt @@ -339,13 +339,16 @@ internal fun TranscriptSurface( val outputRevision = lastTurn?.let { "${it.id.value}:${it.status}:${it.output.plainText.length}" } + val viewportHeight by remember { + derivedStateOf { listState.layoutInfo.viewportSize.height } + } LaunchedEffect(userDragging, atBottom) { if (userDragging || atBottom) { followOutput = atBottom } } - LaunchedEffect(transcript.turns.size, outputRevision) { + LaunchedEffect(transcript.turns.size, outputRevision, viewportHeight) { if (followOutput && transcript.turns.isNotEmpty()) { listState.scrollToItem(transcript.turns.size) } diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 6215869..56c9655 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -51,8 +51,8 @@ visual case. ## Transcript viewport with the software keyboard -**Status:** Deferred functional UX bug; alpha.8 command execution remains -usable, but normal transcript work requires avoidable recovery scrolling. +**Status:** Implemented in source with emulator regression coverage; physical +owner-device validation remains pending. On the Galaxy S25 Ultra, focusing the transcript composer and sending commands with the software keyboard open moved the useful transcript content above the @@ -61,11 +61,19 @@ user had to scroll back up to recover the new cards and output. Connection, execution, and data remained intact. The recovery scrolling is repeated friction in the primary interaction path. -When this is fixed, test focus, submission, running output, completion, -tail-following, manual user scrolling, keyboard dismissal, rotation, and large -font sizes together. The current or newly completed turn should remain easy to -reach while the composer stays usable. Do not force-scroll a user who has -deliberately moved into older output. +The transcript followed new output but did not re-anchor the followed tail when +the software keyboard changed the list viewport height. Tail-following now +reacts to viewport resizes while preserving the IME padding that keeps the +composer above the keyboard. An instrumented emulator regression uses the real +Gboard window and the full connected-session layout. It failed against the old +layout and verifies that the composer, previous result, and newly submitted +result all remain visible after the fix. The raw terminal keeps its separate +IME handling. + +Owner-device acceptance should repeat focus, submission, running output, +completion, keyboard dismissal, rotation, and large-font checks. Existing +tail-following behavior is unchanged, including the rule that deliberate user +scrolling into older output must not be overridden. ## Responsive onboarding and edge-to-edge polish diff --git a/docs/STATUS.md b/docs/STATUS.md index ea60784..c669e9f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -142,6 +142,14 @@ The next persistent command returned the original marker and `/tmp`, with exit 0 and without reconnecting. This closes alpha.8 owner-device acceptance. See the [alpha.8 isolated-execution investigation](investigations/2026-08-17-alpha8-isolated-execution-acceptance.md). +Post-alpha.8 source work keeps a followed transcript tail anchored when the +software keyboard changes the list viewport height. A connected-session +emulator regression using the real Gboard window reproduces the prior loss of +the newest useful card, then verifies that the composer, previous result, and a +newly submitted result remain visible after the fix. Deliberate user scrolling +still disables tail-following, and the raw terminal path is unchanged. +Owner-device acceptance and the next signed tester build remain pending. + ## Remaining Phase 5 boundaries - Technical-alpha use sufficient to evaluate the Phase 5 exit criterion.