Skip to content

Commit 8167fb0

Browse files
authored
ADFA-3070 | Fix agent chat unscrollable in landscape (#64)
fix(ai-assistant): make the agent chat usable in landscape In landscape the composer claimed nearly the whole screen, leaving no room to read the transcript. Auto-hide it as the chat scrolls and give it a reopen tab, folding it only on a screen that still carries that tab so a running agent is never stranded behind a composer that can't be recovered after process death.
1 parent 7d7274a commit 8167fb0

18 files changed

Lines changed: 662 additions & 96 deletions

‎ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt‎

Lines changed: 121 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
package com.itsaky.androidide.plugins.aiassistant.fragments
22

3+
import android.content.res.Configuration
4+
import android.graphics.Rect
35
import android.os.Bundle
46
import android.view.LayoutInflater
57
import android.view.View
68
import android.view.ViewGroup
9+
import androidx.core.view.ViewCompat
10+
import androidx.core.view.WindowInsetsCompat
11+
import androidx.core.view.doOnAttach
712
import androidx.core.view.isVisible
8-
import androidx.core.widget.doAfterTextChanged
913
import androidx.fragment.app.Fragment
1014
import androidx.lifecycle.Lifecycle
1115
import androidx.lifecycle.ViewModelProvider
1216
import androidx.lifecycle.lifecycleScope
1317
import androidx.lifecycle.repeatOnLifecycle
1418
import androidx.recyclerview.widget.LinearLayoutManager
19+
import androidx.recyclerview.widget.RecyclerView
1520
import com.google.android.material.chip.Chip
1621
import com.google.android.material.snackbar.Snackbar
1722
import com.itsaky.androidide.plugins.PluginContext
@@ -21,6 +26,7 @@ import com.itsaky.androidide.plugins.aiassistant.R
2126
import com.itsaky.androidide.plugins.aiassistant.adapters.ChatAdapter
2227
import com.itsaky.androidide.plugins.aiassistant.databinding.FragmentChatBinding
2328
import com.itsaky.androidide.plugins.aiassistant.models.AgentState
29+
import com.itsaky.androidide.plugins.aiassistant.models.isRunning
2430
import com.itsaky.androidide.plugins.aiassistant.viewmodel.ChatViewModel
2531
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
2632
import com.itsaky.androidide.plugins.services.IdeProjectService
@@ -49,6 +55,11 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
4955
private lateinit var markwon: Markwon
5056
private val contextFiles = mutableListOf<File>()
5157

58+
private var composer: ComposerAutoHideController? = null
59+
60+
/** The message list's layout-declared padding, before any cutout inset is added. */
61+
private val basePadding = Rect()
62+
5263
private val tooltipService: IdeTooltipService? by lazy {
5364
try {
5465
PluginFragmentHelper.getServiceRegistry(AiAssistantPlugin.PLUGIN_ID)
@@ -98,9 +109,17 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
98109
}
99110
super.onDestroyView()
100111
viewModel.stopProcessing()
112+
composer?.detach()
113+
composer = null
101114
_binding = null
102115
}
103116

117+
override fun onSaveInstanceState(outState: Bundle) {
118+
super.onSaveInstanceState(outState)
119+
// The process can be killed while backgrounded even though rotation never recreates us.
120+
composer?.saveState(outState)
121+
}
122+
104123
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
105124
super.onViewCreated(view, savedInstanceState)
106125

@@ -112,6 +131,8 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
112131
setupToolbar()
113132
setupRecyclerView()
114133
setupInputArea()
134+
setupCutoutPadding()
135+
setupComposer(savedInstanceState)
115136
setupStatusBar()
116137
setupBackendIndicator()
117138
observeViewModel()
@@ -226,23 +247,15 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
226247
}
227248

228249
private fun setupInputArea() {
229-
binding.promptInputEdittext.doAfterTextChanged { text ->
230-
binding.sendButton.isEnabled = !text.isNullOrBlank()
231-
}
232-
233250
binding.sendButton.setOnClickListener {
234-
val currentAgentState = viewModel.agentState.value
235-
when (currentAgentState) {
236-
is AgentState.Executing, is AgentState.Processing -> {
237-
viewModel.stopProcessing()
238-
}
239-
else -> {
240-
val message = binding.promptInputEdittext.text?.toString() ?: return@setOnClickListener
241-
if (message.isNotBlank()) {
242-
hideKeyboard()
243-
viewModel.sendMessage(message)
244-
binding.promptInputEdittext.text?.clear()
245-
}
251+
if (viewModel.agentState.value.isRunning) {
252+
viewModel.stopProcessing()
253+
} else {
254+
val message = binding.promptInputEdittext.text?.toString() ?: return@setOnClickListener
255+
if (message.isNotBlank()) {
256+
composer?.hideKeyboard()
257+
viewModel.sendMessage(message)
258+
binding.promptInputEdittext.text?.clear()
246259
}
247260
}
248261
}
@@ -258,6 +271,70 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
258271
wireTooltip(binding.backendStatusText, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_BACKEND)
259272
}
260273

274+
/**
275+
* Records the message list's own padding so the cutout inset is added to it rather than
276+
* replacing it, and stays correct however many inset passes the window makes.
277+
*/
278+
private fun setupCutoutPadding() {
279+
val list = binding.chatRecyclerView
280+
basePadding.set(list.paddingLeft, list.paddingTop, list.paddingRight, list.paddingBottom)
281+
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
282+
applyCutoutPadding(insets)
283+
insets
284+
}
285+
// No dispatched insets to read on the first attach, so take them from the window.
286+
binding.root.doOnAttach { view ->
287+
ViewCompat.getRootWindowInsets(view)?.let(::applyCutoutPadding)
288+
}
289+
}
290+
291+
/**
292+
* Holds the message list clear of the camera cutout. Portrait puts it in the status bar and
293+
* these insets come back zero; landscape moves it onto a side edge, right where message text
294+
* would otherwise start. The toolbar and composer keep their own edge-to-edge alignment.
295+
*/
296+
private fun applyCutoutPadding(insets: WindowInsetsCompat) {
297+
val binding = _binding ?: return
298+
val cutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout())
299+
binding.chatRecyclerView.setPadding(
300+
basePadding.left + cutout.left,
301+
basePadding.top,
302+
basePadding.right + cutout.right,
303+
basePadding.bottom,
304+
)
305+
}
306+
307+
/**
308+
* On a short screen the composer folds away so the history gets its height, and a floating
309+
* chevron brings it back. Everywhere else the composer stays pinned and the chevron is absent.
310+
*/
311+
private fun setupComposer(savedInstanceState: Bundle?) {
312+
val controller = ComposerAutoHideController(
313+
binding,
314+
viewLifecycleOwner.lifecycleScope,
315+
::wireTooltip,
316+
)
317+
// The fragment's own Resources, which is the host activity's and tracks rotation.
318+
controller.attach(savedInstanceState, resources.configuration)
319+
composer = controller
320+
}
321+
322+
/**
323+
* EditorActivity handles orientation itself, so rotating never recreates this fragment and an
324+
* alternative-resource bucket would stay frozen at the orientation it was inflated in. The one
325+
* thing that must track rotation is re-applied here instead.
326+
*/
327+
override fun onConfigurationChanged(newConfig: Configuration) {
328+
super.onConfigurationChanged(newConfig)
329+
val binding = _binding ?: return
330+
composer?.onConfigurationChanged(newConfig)
331+
// Posted so the window has published the rotated cutout before it is read back.
332+
binding.root.post {
333+
val root = _binding?.root ?: return@post
334+
ViewCompat.getRootWindowInsets(root)?.let(::applyCutoutPadding)
335+
}
336+
}
337+
261338
private fun setupStatusBar() {
262339
binding.agentStatusContainer.isVisible = false
263340
}
@@ -266,7 +343,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
266343
viewLifecycleOwner.lifecycleScope.launch {
267344
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
268345
viewModel.activeBackendLabel.collect { label ->
269-
binding.backendStatusText.text = label
346+
_binding?.backendStatusText?.text = label
270347
}
271348
}
272349
}
@@ -285,56 +362,54 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
285362
private suspend fun observeMessages() {
286363
android.util.Log.d("ChatFragment", "observeMessages: Starting to collect messages")
287364
viewModel.messages.collect { messages ->
365+
val binding = _binding ?: return@collect
288366
android.util.Log.d("ChatFragment", "observeMessages: Received ${messages.size} messages")
289367
messages.forEachIndexed { index, msg ->
290368
android.util.Log.d("ChatFragment", " Message $index: sender=${msg.sender}, text=${msg.text.take(50)}")
291369
}
292370
binding.emptyChatView.isVisible = messages.isEmpty()
293371
android.util.Log.d("ChatFragment", "observeMessages: Calling submitList with ${messages.size} messages")
372+
// Sampled before the list changes: streaming re-emits on every token, so scrolling
373+
// unconditionally would drag the user back down whenever they scrolled up to read.
374+
val stickToBottom = binding.chatRecyclerView.isAtBottom()
294375
chatAdapter.submitList(messages) {
295376
android.util.Log.d("ChatFragment", "observeMessages: submitList callback - scrolling to ${messages.size - 1}")
296-
if (messages.isNotEmpty()) {
297-
binding.chatRecyclerView.scrollToPosition(messages.size - 1)
377+
if (stickToBottom && messages.isNotEmpty()) {
378+
// Null after onDestroyView: submitList posts this callback.
379+
_binding?.chatRecyclerView?.scrollToPosition(messages.lastIndex)
298380
}
299381
}
300382
}
301383
}
302384

385+
/** True while the newest message is fully visible, i.e. the user is not reading back. */
386+
private fun RecyclerView.isAtBottom(): Boolean = !canScrollVertically(1)
387+
303388
private suspend fun observeAgentState() {
304389
viewModel.agentState.collect { state ->
390+
val binding = _binding ?: return@collect
305391
when (state) {
306-
is AgentState.Idle -> {
307-
binding.agentStatusContainer.isVisible = false
308-
binding.sendButton.isEnabled = true
309-
binding.sendButton.text = getString(R.string.send)
310-
}
392+
is AgentState.Idle -> binding.agentStatusContainer.isVisible = false
311393
is AgentState.Executing -> {
312394
binding.agentStatusContainer.isVisible = true
313395
binding.agentStatusMessage.text = state.formattedProgress
314396
binding.agentStatusTimer.text = state.formattedTiming
315-
binding.sendButton.isEnabled = true
316-
binding.sendButton.text = getString(R.string.btn_stop)
317397
viewModel.startStateTimer(state)
318398
}
319399
is AgentState.Processing -> {
320400
binding.agentStatusContainer.isVisible = true
321401
binding.agentStatusMessage.text = getString(R.string.generating_response)
322402
binding.agentStatusTimer.text = ""
323-
binding.sendButton.isEnabled = true
324-
binding.sendButton.text = getString(R.string.btn_stop)
325403
}
326404
is AgentState.Error -> {
327405
binding.agentStatusContainer.isVisible = false
328-
binding.sendButton.isEnabled = true
329-
binding.sendButton.text = getString(R.string.send)
330406
viewModel.stopStateTimer()
331407
showErrorSnackbar(state.message)
332408
}
333-
else -> {
334-
binding.sendButton.isEnabled = false
335-
binding.sendButton.text = getString(R.string.send)
336-
}
409+
else -> Unit
337410
}
411+
// The composer owns the send/stop button, since Stop is what pins it open.
412+
composer?.onAgentRunningChanged(state.isRunning)
338413
}
339414
}
340415

@@ -386,6 +461,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
386461
val dialog = FilePickerDialogFragment.newInstance(startPath) { files ->
387462
addContextFiles(files)
388463
}
464+
composer?.pauseUntilClosed(dialog.lifecycle)
389465
dialog.show(parentFragmentManager, "file_picker")
390466
}
391467

@@ -407,9 +483,19 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
407483
contextFiles.remove(file)
408484
binding.contextChipGroup.removeView(this)
409485
viewModel.setContextFiles(contextFiles)
486+
updateContextChipVisibility()
410487
}
411488
}
412489
binding.contextChipGroup.addView(chip)
490+
updateContextChipVisibility()
491+
}
492+
493+
/**
494+
* Keeps the chip row out of the layout while empty; in a short bottom sheet the row it would
495+
* otherwise occupy comes straight out of the chat history's height.
496+
*/
497+
private fun updateContextChipVisibility() {
498+
binding.contextChipScroll.isVisible = binding.contextChipGroup.childCount > 0
413499
}
414500

415501
private fun onMessageAction(action: String, message: com.itsaky.androidide.plugins.aiassistant.models.ChatMessage) {
@@ -430,11 +516,6 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
430516
}
431517
}
432518

433-
private fun hideKeyboard() {
434-
val imm = requireContext().getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
435-
imm.hideSoftInputFromWindow(binding.promptInputEdittext.windowToken, 0)
436-
}
437-
438519
/**
439520
* Open the Agent settings screen — the same one Preferences → Configuration → Agent opens, so
440521
* there is one implementation of it. The host mounts it full-screen in PluginScreenActivity;

0 commit comments

Comments
 (0)