diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt index 912cda3..45862cb 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt @@ -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() diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt index cf81906..c38baf1 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt @@ -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}") { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt index e3fa802..5e43639 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt @@ -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 @@ -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() @@ -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 { @@ -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) { @@ -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, ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationState.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationState.kt index 3f7893e..55229a9 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationState.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationState.kt @@ -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 diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index b461ea2..0547adb 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -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, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt index d74a3d3..35c274a 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt @@ -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) @@ -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) @@ -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, @@ -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, ) } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/navigation/ScreenChatRouteTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/navigation/ScreenChatRouteTest.kt new file mode 100644 index 0000000..490e7ab --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/navigation/ScreenChatRouteTest.kt @@ -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), + ) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationTest.kt index 44212d4..72f211c 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationTest.kt @@ -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()