diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt index 9054605c..3a56c401 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt @@ -1,17 +1,22 @@ package com.itsaky.androidide.plugins.aiassistant.fragments +import android.content.res.Configuration +import android.graphics.Rect import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnAttach import androidx.core.view.isVisible -import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar import com.itsaky.androidide.plugins.PluginContext @@ -21,6 +26,7 @@ import com.itsaky.androidide.plugins.aiassistant.R import com.itsaky.androidide.plugins.aiassistant.adapters.ChatAdapter import com.itsaky.androidide.plugins.aiassistant.databinding.FragmentChatBinding import com.itsaky.androidide.plugins.aiassistant.models.AgentState +import com.itsaky.androidide.plugins.aiassistant.models.isRunning import com.itsaky.androidide.plugins.aiassistant.viewmodel.ChatViewModel import com.itsaky.androidide.plugins.base.PluginFragmentHelper import com.itsaky.androidide.plugins.services.IdeProjectService @@ -49,6 +55,11 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { private lateinit var markwon: Markwon private val contextFiles = mutableListOf() + private var composer: ComposerAutoHideController? = null + + /** The message list's layout-declared padding, before any cutout inset is added. */ + private val basePadding = Rect() + private val tooltipService: IdeTooltipService? by lazy { try { PluginFragmentHelper.getServiceRegistry(AiAssistantPlugin.PLUGIN_ID) @@ -98,9 +109,17 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { } super.onDestroyView() viewModel.stopProcessing() + composer?.detach() + composer = null _binding = null } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + // The process can be killed while backgrounded even though rotation never recreates us. + composer?.saveState(outState) + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -112,6 +131,8 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { setupToolbar() setupRecyclerView() setupInputArea() + setupCutoutPadding() + setupComposer(savedInstanceState) setupStatusBar() setupBackendIndicator() observeViewModel() @@ -226,23 +247,15 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { } private fun setupInputArea() { - binding.promptInputEdittext.doAfterTextChanged { text -> - binding.sendButton.isEnabled = !text.isNullOrBlank() - } - binding.sendButton.setOnClickListener { - val currentAgentState = viewModel.agentState.value - when (currentAgentState) { - is AgentState.Executing, is AgentState.Processing -> { - viewModel.stopProcessing() - } - else -> { - val message = binding.promptInputEdittext.text?.toString() ?: return@setOnClickListener - if (message.isNotBlank()) { - hideKeyboard() - viewModel.sendMessage(message) - binding.promptInputEdittext.text?.clear() - } + if (viewModel.agentState.value.isRunning) { + viewModel.stopProcessing() + } else { + val message = binding.promptInputEdittext.text?.toString() ?: return@setOnClickListener + if (message.isNotBlank()) { + composer?.hideKeyboard() + viewModel.sendMessage(message) + binding.promptInputEdittext.text?.clear() } } } @@ -258,6 +271,70 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { wireTooltip(binding.backendStatusText, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_BACKEND) } + /** + * Records the message list's own padding so the cutout inset is added to it rather than + * replacing it, and stays correct however many inset passes the window makes. + */ + private fun setupCutoutPadding() { + val list = binding.chatRecyclerView + basePadding.set(list.paddingLeft, list.paddingTop, list.paddingRight, list.paddingBottom) + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets -> + applyCutoutPadding(insets) + insets + } + // No dispatched insets to read on the first attach, so take them from the window. + binding.root.doOnAttach { view -> + ViewCompat.getRootWindowInsets(view)?.let(::applyCutoutPadding) + } + } + + /** + * Holds the message list clear of the camera cutout. Portrait puts it in the status bar and + * these insets come back zero; landscape moves it onto a side edge, right where message text + * would otherwise start. The toolbar and composer keep their own edge-to-edge alignment. + */ + private fun applyCutoutPadding(insets: WindowInsetsCompat) { + val binding = _binding ?: return + val cutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + binding.chatRecyclerView.setPadding( + basePadding.left + cutout.left, + basePadding.top, + basePadding.right + cutout.right, + basePadding.bottom, + ) + } + + /** + * On a short screen the composer folds away so the history gets its height, and a floating + * chevron brings it back. Everywhere else the composer stays pinned and the chevron is absent. + */ + private fun setupComposer(savedInstanceState: Bundle?) { + val controller = ComposerAutoHideController( + binding, + viewLifecycleOwner.lifecycleScope, + ::wireTooltip, + ) + // The fragment's own Resources, which is the host activity's and tracks rotation. + controller.attach(savedInstanceState, resources.configuration) + composer = controller + } + + /** + * EditorActivity handles orientation itself, so rotating never recreates this fragment and an + * alternative-resource bucket would stay frozen at the orientation it was inflated in. The one + * thing that must track rotation is re-applied here instead. + */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + val binding = _binding ?: return + composer?.onConfigurationChanged(newConfig) + // Posted so the window has published the rotated cutout before it is read back. + binding.root.post { + val root = _binding?.root ?: return@post + ViewCompat.getRootWindowInsets(root)?.let(::applyCutoutPadding) + } + } + private fun setupStatusBar() { binding.agentStatusContainer.isVisible = false } @@ -266,7 +343,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.activeBackendLabel.collect { label -> - binding.backendStatusText.text = label + _binding?.backendStatusText?.text = label } } } @@ -285,56 +362,54 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { private suspend fun observeMessages() { android.util.Log.d("ChatFragment", "observeMessages: Starting to collect messages") viewModel.messages.collect { messages -> + val binding = _binding ?: return@collect android.util.Log.d("ChatFragment", "observeMessages: Received ${messages.size} messages") messages.forEachIndexed { index, msg -> android.util.Log.d("ChatFragment", " Message $index: sender=${msg.sender}, text=${msg.text.take(50)}") } binding.emptyChatView.isVisible = messages.isEmpty() android.util.Log.d("ChatFragment", "observeMessages: Calling submitList with ${messages.size} messages") + // Sampled before the list changes: streaming re-emits on every token, so scrolling + // unconditionally would drag the user back down whenever they scrolled up to read. + val stickToBottom = binding.chatRecyclerView.isAtBottom() chatAdapter.submitList(messages) { android.util.Log.d("ChatFragment", "observeMessages: submitList callback - scrolling to ${messages.size - 1}") - if (messages.isNotEmpty()) { - binding.chatRecyclerView.scrollToPosition(messages.size - 1) + if (stickToBottom && messages.isNotEmpty()) { + // Null after onDestroyView: submitList posts this callback. + _binding?.chatRecyclerView?.scrollToPosition(messages.lastIndex) } } } } + /** True while the newest message is fully visible, i.e. the user is not reading back. */ + private fun RecyclerView.isAtBottom(): Boolean = !canScrollVertically(1) + private suspend fun observeAgentState() { viewModel.agentState.collect { state -> + val binding = _binding ?: return@collect when (state) { - is AgentState.Idle -> { - binding.agentStatusContainer.isVisible = false - binding.sendButton.isEnabled = true - binding.sendButton.text = getString(R.string.send) - } + is AgentState.Idle -> binding.agentStatusContainer.isVisible = false is AgentState.Executing -> { binding.agentStatusContainer.isVisible = true binding.agentStatusMessage.text = state.formattedProgress binding.agentStatusTimer.text = state.formattedTiming - binding.sendButton.isEnabled = true - binding.sendButton.text = getString(R.string.btn_stop) viewModel.startStateTimer(state) } is AgentState.Processing -> { binding.agentStatusContainer.isVisible = true binding.agentStatusMessage.text = getString(R.string.generating_response) binding.agentStatusTimer.text = "" - binding.sendButton.isEnabled = true - binding.sendButton.text = getString(R.string.btn_stop) } is AgentState.Error -> { binding.agentStatusContainer.isVisible = false - binding.sendButton.isEnabled = true - binding.sendButton.text = getString(R.string.send) viewModel.stopStateTimer() showErrorSnackbar(state.message) } - else -> { - binding.sendButton.isEnabled = false - binding.sendButton.text = getString(R.string.send) - } + else -> Unit } + // The composer owns the send/stop button, since Stop is what pins it open. + composer?.onAgentRunningChanged(state.isRunning) } } @@ -386,6 +461,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { val dialog = FilePickerDialogFragment.newInstance(startPath) { files -> addContextFiles(files) } + composer?.pauseUntilClosed(dialog.lifecycle) dialog.show(parentFragmentManager, "file_picker") } @@ -407,9 +483,19 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { contextFiles.remove(file) binding.contextChipGroup.removeView(this) viewModel.setContextFiles(contextFiles) + updateContextChipVisibility() } } binding.contextChipGroup.addView(chip) + updateContextChipVisibility() + } + + /** + * Keeps the chip row out of the layout while empty; in a short bottom sheet the row it would + * otherwise occupy comes straight out of the chat history's height. + */ + private fun updateContextChipVisibility() { + binding.contextChipScroll.isVisible = binding.contextChipGroup.childCount > 0 } private fun onMessageAction(action: String, message: com.itsaky.androidide.plugins.aiassistant.models.ChatMessage) { @@ -430,11 +516,6 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { } } - private fun hideKeyboard() { - val imm = requireContext().getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager - imm.hideSoftInputFromWindow(binding.promptInputEdittext.windowToken, 0) - } - /** * Open the Agent settings screen — the same one Preferences → Configuration → Agent opens, so * there is one implementation of it. The host mounts it full-screen in PluginScreenActivity; diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ComposerAutoHideController.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ComposerAutoHideController.kt new file mode 100644 index 00000000..aa34b360 --- /dev/null +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ComposerAutoHideController.kt @@ -0,0 +1,203 @@ +package com.itsaky.androidide.plugins.aiassistant.fragments + +import android.content.Context +import android.content.res.Configuration +import android.os.Bundle +import android.view.View +import android.view.accessibility.AccessibilityManager +import android.view.inputmethod.InputMethodManager +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin +import com.itsaky.androidide.plugins.aiassistant.R +import com.itsaky.androidide.plugins.aiassistant.databinding.FragmentChatBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Owns the composer: whether it is on screen, the idle countdown that folds it away on a short + * screen, and the send/stop button it carries. Attached in onViewCreated and detached in + * onDestroyView, so nothing it schedules outlives the views it drives. + */ +internal class ComposerAutoHideController( + binding: FragmentChatBinding, + private val scope: CoroutineScope, + private val wireTooltip: (View, String) -> Unit, +) { + + private companion object { + const val KEY_COMPOSER_VISIBLE = "composer_visible" + const val KEY_COMPOSER_AUTO_HIDE = "composer_auto_hide" + } + + private var _binding: FragmentChatBinding? = binding + + private val autoHideMs = + binding.root.resources.getInteger(R.integer.chat_composer_auto_hide_ms).toLong() + private val compactHeightDp = + binding.root.resources.getInteger(R.integer.chat_composer_compact_height_dp) + + /** True only while the screen is too short to keep the composer pinned. */ + private var autoHide = false + private var agentRunning = false + private var countdown: Job? = null + + /** + * Wires the composer's controls and puts it in the state [savedState] left behind, or open on + * a first run. [config] comes from the fragment so this never has to reach for an Activity. + */ + fun attach(savedState: Bundle?, config: Configuration) { + val binding = _binding ?: return + binding.btnShowComposer.setOnClickListener { setVisible(true) } + binding.btnHideComposer.setOnClickListener { setVisible(false) } + wireTooltip(binding.btnShowComposer, AiAssistantPlugin.TOOLTIP_TAG_CHAT_INPUT) + wireTooltip(binding.btnHideComposer, AiAssistantPlugin.TOOLTIP_TAG_CHAT_INPUT) + + // Focus means the user is mid-thought, so the countdown waits until they step away. + binding.promptInputEdittext.setOnFocusChangeListener { _, _ -> onUserInteraction() } + binding.promptInputEdittext.doAfterTextChanged { onUserInteraction() } + + autoHide = savedState?.getBoolean(KEY_COMPOSER_AUTO_HIDE) ?: false + applyCompactRule(config, visible = savedState?.getBoolean(KEY_COMPOSER_VISIBLE) ?: true) + } + + /** Stops the countdown and releases the views; call from onDestroyView. */ + fun detach() { + countdown?.cancel() + countdown = null + _binding = null + } + + /** + * Re-evaluates the compact-screen rule after a rotation, always landing on an open composer: + * rotating into portrait must never leave it folded away with the tab that would restore it + * now gone. + */ + fun onConfigurationChanged(config: Configuration) = + applyCompactRule(config, visible = true) + + /** + * Switches the trailing button between Send and Stop. Stop lives on that button, so starting a + * run opens the composer and holds off the idle countdown until the run ends. Hide still folds + * it away on request — that is the user's call, and the reopen tab brings Stop straight back. + */ + fun onAgentRunningChanged(running: Boolean) { + agentRunning = running + val binding = _binding ?: return + val context = binding.root.context + if (running) { + binding.sendButton.setImageResource(R.drawable.ic_stop) + binding.sendButton.contentDescription = context.getString(R.string.desc_stop_agent) + // setVisible re-schedules, and canAutoHide already refuses while the agent runs. + setVisible(true) + } else { + binding.sendButton.setImageResource(R.drawable.ic_send) + binding.sendButton.contentDescription = context.getString(R.string.desc_send_message) + schedule() + } + updateSendAppearance() + } + + /** The composer is in use — text edited, focus moved — so the countdown starts over. */ + fun onUserInteraction() { + updateSendAppearance() + schedule() + } + + /** Holds the countdown while a modal is up: nothing behind it counts as the user going idle. */ + fun pauseUntilClosed(lifecycle: Lifecycle) { + countdown?.cancel() + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) = schedule() + }) + } + + /** Dismisses the soft keyboard raised by the input field. */ + fun hideKeyboard() { + val binding = _binding ?: return + val imm = binding.root.context + .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(binding.promptInputEdittext.windowToken, 0) + } + + /** + * Saves the composer across process death. The auto-hide flag is written too, but + * [applyCompactRule] re-derives it on restore: the process can come back in an orientation + * other than the one it died in, and the saved flag would describe a screen that is gone. + */ + fun saveState(outState: Bundle) { + outState.putBoolean(KEY_COMPOSER_AUTO_HIDE, autoHide) + outState.putBoolean(KEY_COMPOSER_VISIBLE, _binding?.inputBarCard?.isVisible ?: true) + } + + /** + * Turns auto-hide on for short screens only, then settles the composer on [visible]. A folded + * composer is asked for, not obeyed: only a compact screen carries the tab that reopens it, so + * a taller one always lands open however it was left. + */ + private fun applyCompactRule(config: Configuration, visible: Boolean) { + autoHide = config.screenHeightDp < compactHeightDp + setVisible(!autoHide || visible) + } + + /** + * Swaps which of the two tabs is on screen. They are separate views because each belongs on a + * different side of the group's top border: the collapsed one stands above it, the expanded + * one hangs below it, out of the chat text. + */ + private fun setVisible(visible: Boolean) { + val binding = _binding ?: return + binding.inputBarCard.isVisible = visible + binding.btnShowComposer.isVisible = autoHide && !visible + binding.btnHideComposer.isVisible = autoHide && visible + if (visible) { + schedule() + } else { + countdown?.cancel() + binding.promptInputEdittext.clearFocus() + hideKeyboard() + } + } + + /** Restarts the idle countdown. Safe to call from any interaction; it no-ops when it must. */ + private fun schedule() { + countdown?.cancel() + val binding = _binding ?: return + if (!autoHide || !binding.inputBarCard.isVisible || !canAutoHide()) return + countdown = scope.launch { + delay(autoHideMs) + setVisible(false) + } + } + + /** + * Guards against folding the composer away at a moment the user would lose something: a draft, + * the caret, the Stop button, or a screen reader's only route to the controls. + */ + private fun canAutoHide(): Boolean { + val binding = _binding ?: return false + if (binding.promptInputEdittext.hasFocus()) return false + if (!binding.promptInputEdittext.text.isNullOrBlank()) return false + if (isTouchExplorationEnabled()) return false + return !agentRunning + } + + /** Greys the send icon out on empty input; Stop stays lit because it is always actionable. */ + private fun updateSendAppearance() { + val binding = _binding ?: return + binding.sendButton.isActivated = + agentRunning || !binding.promptInputEdittext.text.isNullOrBlank() + } + + private fun isTouchExplorationEnabled(): Boolean { + val context = _binding?.root?.context ?: return false + val manager = + context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager + return manager?.isTouchExplorationEnabled == true + } +} diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentState.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentState.kt index 65b32808..5ab66982 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentState.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentState.kt @@ -76,3 +76,10 @@ sealed class AgentState { */ data class Error(val message: String) : AgentState() } + +/** + * True while a run is in flight, which is what the composer keys its Stop control off. One + * definition so the UI cannot drift from it a state at a time. + */ +val AgentState.isRunning: Boolean + get() = this is AgentState.Executing || this is AgentState.Processing diff --git a/ai-assistant/src/main/res/color/chat_send_tint.xml b/ai-assistant/src/main/res/color/chat_send_tint.xml new file mode 100644 index 00000000..566e950d --- /dev/null +++ b/ai-assistant/src/main/res/color/chat_send_tint.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/ai-assistant/src/main/res/drawable/bg_chat_composer.xml b/ai-assistant/src/main/res/drawable/bg_chat_composer.xml new file mode 100644 index 00000000..5ca436c5 --- /dev/null +++ b/ai-assistant/src/main/res/drawable/bg_chat_composer.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/ai-assistant/src/main/res/drawable/bg_composer_tab_down.xml b/ai-assistant/src/main/res/drawable/bg_composer_tab_down.xml new file mode 100644 index 00000000..c1b0afb6 --- /dev/null +++ b/ai-assistant/src/main/res/drawable/bg_composer_tab_down.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + diff --git a/ai-assistant/src/main/res/drawable/bg_composer_tab_up.xml b/ai-assistant/src/main/res/drawable/bg_composer_tab_up.xml new file mode 100644 index 00000000..bcc2f42c --- /dev/null +++ b/ai-assistant/src/main/res/drawable/bg_composer_tab_up.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/ai-assistant/src/main/res/drawable/bg_input_bar.xml b/ai-assistant/src/main/res/drawable/bg_input_bar.xml new file mode 100644 index 00000000..8cfa2e16 --- /dev/null +++ b/ai-assistant/src/main/res/drawable/bg_input_bar.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/ai-assistant/src/main/res/drawable/ic_attach_file.xml b/ai-assistant/src/main/res/drawable/ic_attach_file.xml new file mode 100644 index 00000000..035f314a --- /dev/null +++ b/ai-assistant/src/main/res/drawable/ic_attach_file.xml @@ -0,0 +1,9 @@ + + + diff --git a/ai-assistant/src/main/res/drawable/ic_expand_less.xml b/ai-assistant/src/main/res/drawable/ic_expand_less.xml new file mode 100644 index 00000000..340315dc --- /dev/null +++ b/ai-assistant/src/main/res/drawable/ic_expand_less.xml @@ -0,0 +1,9 @@ + + + diff --git a/ai-assistant/src/main/res/drawable/ic_expand_more.xml b/ai-assistant/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 00000000..261bf3ff --- /dev/null +++ b/ai-assistant/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/ai-assistant/src/main/res/drawable/ic_more_vert.xml b/ai-assistant/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 00000000..0a80a17c --- /dev/null +++ b/ai-assistant/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/ai-assistant/src/main/res/drawable/ic_send.xml b/ai-assistant/src/main/res/drawable/ic_send.xml new file mode 100644 index 00000000..dfe6477f --- /dev/null +++ b/ai-assistant/src/main/res/drawable/ic_send.xml @@ -0,0 +1,9 @@ + + + diff --git a/ai-assistant/src/main/res/drawable/ic_stop.xml b/ai-assistant/src/main/res/drawable/ic_stop.xml new file mode 100644 index 00000000..6a7b7ce3 --- /dev/null +++ b/ai-assistant/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,9 @@ + + + diff --git a/ai-assistant/src/main/res/layout/fragment_chat.xml b/ai-assistant/src/main/res/layout/fragment_chat.xml index d6b36c57..888ee9f3 100644 --- a/ai-assistant/src/main/res/layout/fragment_chat.xml +++ b/ai-assistant/src/main/res/layout/fragment_chat.xml @@ -1,4 +1,10 @@ + -