Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@ fun ChatInputBar(
snapshotFlow { textState.text.toString() }.collect { onValueChange(it) }
}

// Request focus when triggered
LaunchedEffect(requestFocus) {
// Initial focus is a navigation decision. Do not reopen the keyboard when the user returns
// to this tab or after they dismiss it manually.
LaunchedEffect(Unit) {
if (requestFocus) {
try {
focusRequester.requestFocus()
Expand Down
8 changes: 6 additions & 2 deletions app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ sealed class Screen(val route: String) {
data object Home : Screen("home")
data object Sessions : Screen("sessions")

data object Chat : Screen("chat/{sessionId}") {
fun createRoute(sessionId: String) = "chat/${Uri.encode(sessionId)}"
data object Chat : Screen("chat/{sessionId}?focusInput={focusInput}") {
fun createRoute(sessionId: String, focusInput: Boolean = false): String {
val route = "chat/${sessionId.routeEncode()}"
return if (focusInput) "$route?focusInput=true" else route
}
const val ARG_SESSION_ID = "sessionId"
const val ARG_FOCUS_INPUT = "focusInput"
}

data object Terminal : Screen("terminal/{ptyId}") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
Expand Down Expand Up @@ -112,7 +113,8 @@ fun ChatScreen(
onProviderAuthRequired: ((String) -> Unit)? = null,
onSessionLoaded: ((sessionId: String, sessionTitle: String) -> Unit)? = null,
onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null,
isActiveTab: Boolean = true
isActiveTab: Boolean = true,
requestInitialInputFocus: Boolean = false,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val messages by viewModel.messages.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -198,6 +200,11 @@ fun ChatScreen(
findChatMatches(messageBlocks, scrollRestorationState.searchQuery)
}
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
val imeBottom = WindowInsets.ime.getBottom(density)
val isImeVisible = imeBottom > 0
var wasImeVisible by remember(uiState.session?.id) { mutableStateOf(false) }
var keepTailVisibleDuringImeOpen by remember(uiState.session?.id) { mutableStateOf(false) }

// Derived state: check if the bottom edge of the last rendered item is visible.
val isAtBottom by remember {
Expand Down Expand Up @@ -264,6 +271,24 @@ fun ChatScreen(
}
}

// When the user opens the composer keyboard, keep the latest conversation content visible.
// This is an explicit input action, so it intentionally returns a previously scrolled chat
// to the tail instead of leaving the keyboard covering the newest messages.
LaunchedEffect(imeBottom) {
if (!isImeVisible) {
wasImeVisible = false
keepTailVisibleDuringImeOpen = false
return@LaunchedEffect
}
if (!wasImeVisible) {
keepTailVisibleDuringImeOpen = scrollRestorationState.onKeyboardOpened(messageCount > 0)
}
if (keepTailVisibleDuringImeOpen) {
listState.scrollChatToBottom()
}
wasImeVisible = true
}

// Permissions can arrive for a tool rendered far above the current viewport without changing
// the message tail. Treat a newly pending call as explicit attention and reveal the approval UI.
LaunchedEffect(pendingPermissionVersion) {
Expand Down Expand Up @@ -387,7 +412,7 @@ fun ChatScreen(
commandLoadError = uiState.commandLoadError,
onRetryCommands = { viewModel.refreshCommandsIfNeeded(force = true) },
onCommandSelected = { /* Command text is already updated via onValueChange */ },
requestFocus = isActiveTab,
requestFocus = isActiveTab && requestInitialInputFocus,
enterToSend = chatSettings.enterToSend,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ internal class ChatScrollRestorationState(
hasNewContentWhileAway = false
}

fun onKeyboardOpened(hasRenderableTail: Boolean): Boolean {
if (!hasRenderableTail) return false
onJumpToBottom()
return true
}

fun onContentReady(hasRenderableTail: Boolean): InitialTailDecision {
val decision = when {
!hasRenderableTail -> InitialTailDecision.NoContent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,7 @@ private fun mainTabPendingStartWorkEffect(
is ApiResult.Success -> {
uiState.pendingStartWork = null
deps.tabManager.createTab(
startRoute = Screen.Chat.createRoute(result.data.id),
startRoute = Screen.Chat.createRoute(result.data.id, focusInput = true),
workspaceKey = target.workspaceKey,
serverRef = target.serverRef,
focus = true,
Expand Down
17 changes: 13 additions & 4 deletions app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ fun TabNavHost(
onNewSession = { sessionId, directory ->
val selection = directory.toNavigationWorkspaceSelection()
?: return@SessionListScreen
val chatRoute = Screen.Chat.createRoute(sessionId)
val chatRoute = Screen.Chat.createRoute(sessionId, focusInput = true)
if (selection.directory != workspaceOwner.workspace.directory) {
pendingRoute = chatRoute
tabManager.updateTabWorkspace(tabId, selection.workspaceKey)
Expand Down Expand Up @@ -303,7 +303,7 @@ fun TabNavHost(
onNewSession = { sessionId, directory ->
val selection = directory.toNavigationWorkspaceSelection()
?: return@SessionListScreen
val chatRoute = Screen.Chat.createRoute(sessionId)
val chatRoute = Screen.Chat.createRoute(sessionId, focusInput = true)
if (selection.directory != workspaceOwner.workspace.directory) {
pendingRoute = chatRoute
tabManager.updateTabWorkspace(tabId, selection.workspaceKey)
Expand Down Expand Up @@ -346,7 +346,13 @@ fun TabNavHost(
// Chat screen
composable(
route = Screen.Chat.route,
arguments = listOf(navArgument(Screen.Chat.ARG_SESSION_ID) { type = NavType.StringType })
arguments = listOf(
navArgument(Screen.Chat.ARG_SESSION_ID) { type = NavType.StringType },
navArgument(Screen.Chat.ARG_FOCUS_INPUT) {
type = NavType.BoolType
defaultValue = false
},
)
) { backStackEntry ->
val workspaceViewModel = TouchWorkspaceViewModel(
backStackEntry,
Expand Down Expand Up @@ -401,7 +407,10 @@ fun TabNavHost(
tabManager.updateTabSession(tabId, sessionId, sessionTitle)
},
onConnectionStateChanged = onConnectionStateChanged,
isActiveTab = isActiveTab
isActiveTab = isActiveTab,
requestInitialInputFocus = backStackEntry.arguments
?.getBoolean(Screen.Chat.ARG_FOCUS_INPUT)
?: false,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package dev.blazelight.p4oc.ui.navigation

import org.junit.Assert.assertEquals
import org.junit.Test

class ScreenChatRouteTest {
@Test
fun `existing session route keeps input unfocused`() {
assertEquals("chat/session%2Fone", Screen.Chat.createRoute("session/one"))
}

@Test
fun `new session route requests initial input focus`() {
assertEquals(
"chat/session%2Fone?focusInput=true",
Screen.Chat.createRoute("session/one", focusInput = true),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,28 @@ class ChatScrollRestorationTest {
assertFalse(state.hasNewContentWhileAway)
}

@Test
fun openingKeyboardReturnsExistingConversationToTail() {
val state = ChatScrollRestorationState()
state.onContentReady(hasRenderableTail = true)
state.onScrollSettled(isAtBottom = false)
state.onTailContentChanged(hasRenderableTail = true)

val shouldScroll = state.onKeyboardOpened(hasRenderableTail = true)

assertTrue(shouldScroll)
assertTrue(state.shouldFollowTail)
assertFalse(state.hasNewContentWhileAway)
}

@Test
fun openingKeyboardDoesNotRequestScrollForEmptyConversation() {
val state = ChatScrollRestorationState()

assertFalse(state.onKeyboardOpened(hasRenderableTail = false))
assertTrue(state.shouldFollowTail)
}

@Test
fun contentNotReadyDoesNotConsumeInitialTailRestoration() {
val state = ChatScrollRestorationState()
Expand Down