Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 92 additions & 3 deletions app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -53,7 +61,7 @@ import org.junit.Test

class TranscriptScreenTest {
@get:Rule
val composeRule = createComposeRule()
val composeRule = createAndroidComposeRule<ComponentActivity>()

@Test
fun composerSubmitsExactMultilineCommandAndClearsAfterAcceptance() {
Expand Down Expand Up @@ -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<ViewGroup>(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"),
Expand Down
5 changes: 4 additions & 1 deletion app/src/main/java/dev/threadline/TranscriptScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
22 changes: 15 additions & 7 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
8 changes: 8 additions & 0 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down