11package com.itsaky.androidide.plugins.aiassistant.fragments
22
3+ import android.content.res.Configuration
4+ import android.graphics.Rect
35import android.os.Bundle
46import android.view.LayoutInflater
57import android.view.View
68import android.view.ViewGroup
9+ import androidx.core.view.ViewCompat
10+ import androidx.core.view.WindowInsetsCompat
11+ import androidx.core.view.doOnAttach
712import androidx.core.view.isVisible
8- import androidx.core.widget.doAfterTextChanged
913import androidx.fragment.app.Fragment
1014import androidx.lifecycle.Lifecycle
1115import androidx.lifecycle.ViewModelProvider
1216import androidx.lifecycle.lifecycleScope
1317import androidx.lifecycle.repeatOnLifecycle
1418import androidx.recyclerview.widget.LinearLayoutManager
19+ import androidx.recyclerview.widget.RecyclerView
1520import com.google.android.material.chip.Chip
1621import com.google.android.material.snackbar.Snackbar
1722import com.itsaky.androidide.plugins.PluginContext
@@ -21,6 +26,7 @@ import com.itsaky.androidide.plugins.aiassistant.R
2126import com.itsaky.androidide.plugins.aiassistant.adapters.ChatAdapter
2227import com.itsaky.androidide.plugins.aiassistant.databinding.FragmentChatBinding
2328import com.itsaky.androidide.plugins.aiassistant.models.AgentState
29+ import com.itsaky.androidide.plugins.aiassistant.models.isRunning
2430import com.itsaky.androidide.plugins.aiassistant.viewmodel.ChatViewModel
2531import com.itsaky.androidide.plugins.base.PluginFragmentHelper
2632import 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