From 683dd56b999452ec5676124667957cab971e204b Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Thu, 29 Oct 2026 07:10:08 +0100 Subject: [PATCH 1/3] ADFA-6767 | Put prompt actions beside the bubble and add a full-screen chat --- .../plugins/aicore/fragments/ChatFragment.kt | 42 ++++++- .../fragments/FullScreenChatFragment.kt | 10 ++ .../plugins/aicore/plugin/AiCorePlugin.kt | 18 +++ .../plugins/aicore/views/UserMessageRow.kt | 107 ++++++++++++++++++ .../main/res/drawable/ic_close_fullscreen.xml | 9 ++ .../src/main/res/drawable/ic_open_in_full.xml | 9 ++ .../src/main/res/layout/fragment_chat.xml | 12 ++ .../layout/list_item_chat_user_message.xml | 12 +- .../res/layout/view_chat_message_actions.xml | 4 +- .../AI-Core/src/main/res/values/strings.xml | 3 + 10 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/FullScreenChatFragment.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/views/UserMessageRow.kt create mode 100644 plugins/AI-Core/src/main/res/drawable/ic_close_fullscreen.xml create mode 100644 plugins/AI-Core/src/main/res/drawable/ic_open_in_full.xml diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt index 77224263..2e18defb 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt @@ -72,7 +72,7 @@ private fun String.oneLine(): String = trim().replace(WHITESPACE, " ") * ChatFragment for Agent chat UI. * Provides a full chat interface with LLM integration. */ -class ChatFragment : Fragment(), ApprovalDialogFragment.Host { +open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { private companion object { /** Tag the approval dialog is shown under, so it can be found again after recreation. */ @@ -88,6 +88,12 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { const val STATE_EDIT_FILES = "edit_files" } + /** + * Whether this is the chat shown over the whole screen rather than the one in the bottom + * sheet. Both show the same conversation, since the ViewModel belongs to the plugin. + */ + protected open val isFullScreen: Boolean = false + private var _binding: FragmentChatBinding? = null private val binding get() = _binding!! @@ -302,6 +308,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { syncStorageToCurrentProject() setupChatTitle() setupSidebar() + setupFullScreenToggle() setupRecyclerView() setupInputArea() restoreContextChips() @@ -1066,6 +1073,39 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { } } + /** + * Wires the toolbar's full-screen button: in the bottom sheet it opens this chat over the whole + * screen, and on that screen it closes it again. + */ + private fun setupFullScreenToggle() { + val button = binding.btnChatFullScreen + if (isFullScreen) { + button.setImageResource(R.drawable.ic_close_fullscreen) + button.contentDescription = getString(R.string.desc_exit_full_screen) + // The host's PluginScreenActivity holds nothing but this fragment. + button.setOnClickListener { requireActivity().finish() } + } else { + button.setOnClickListener { openFullScreen() } + } + wireTooltip(button, AiCorePlugin.TOOLTIP_TAG_CHAT_FULL_SCREEN) + } + + /** Opens [FullScreenChatFragment] in the host's full-screen surface. */ + private fun openFullScreen() { + val opened = PluginFragmentHelper.getServiceRegistry(AiCorePlugin.PLUGIN_ID) + ?.get(IdeUIService::class.java) + ?.openPluginScreen( + AiCorePlugin.PLUGIN_ID, + FullScreenChatFragment::class.java.name, + binding.chatTitle.text.toString() + ) ?: false + if (!opened) { + AiCorePlugin.getContext()?.logger + ?.warn("ChatFragment: could not open the chat full screen") + showInfoSnackbar(getString(R.string.msg_full_screen_unavailable)) + } + } + /** * Opens a settings screen another plugin owns, such as MCP's servers list. The host loads the * Fragment with that plugin's class loader, so its own resources and state apply. diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/FullScreenChatFragment.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/FullScreenChatFragment.kt new file mode 100644 index 00000000..27d20f6b --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/FullScreenChatFragment.kt @@ -0,0 +1,10 @@ +package com.itsaky.androidide.plugins.aicore.fragments + +/** + * The Agent chat over the whole screen, opened from the bottom sheet's chat so the transcript gets + * the height the editor, its toolbar and the tabs take there. A class of its own because the host + * opens a plugin screen by class name alone, with no arguments to say which kind it is. + */ +class FullScreenChatFragment : ChatFragment() { + override val isFullScreen: Boolean = true +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt index a26567a2..af3b9cdc 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt @@ -57,6 +57,8 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten const val TOOLTIP_TAG_CHAT_SEND = "agent_chat_send" /** The toolbar button that opens the sidebar; it took the overflow menu's place and tag. */ const val TOOLTIP_TAG_CHAT_MENU = "agent_chat_menu" + /** The toolbar button that opens the chat full screen, and closes it again there. */ + const val TOOLTIP_TAG_CHAT_FULL_SCREEN = "agent_chat_full_screen" const val TOOLTIP_TAG_CHAT_SESSIONS = "agent_chat_sessions" /** The Export entry in a chat row's options menu. */ const val TOOLTIP_TAG_CHAT_EXPORT = "agent_chat_export" @@ -408,6 +410,22 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0) ) ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_CHAT_FULL_SCREEN, + summary = "Shows this chat over the whole screen, or returns to the editor from there.", + detail = """ +

In the bottom sheet, opens the same chat on a screen of its own, + so the conversation gets the height the editor, its toolbar and the + tabs take up. It is the same conversation: a reply still being + written carries on, and anything you send there is in the chat when + you come back.

+

On that screen the same button, or Back, returns you to the + editor.

+ """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0) + ) + ), PluginTooltipEntry( tag = TOOLTIP_TAG_SIDEBAR_ADD, summary = "Start a chat: a new one, or one imported from a .txt file.", diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/views/UserMessageRow.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/views/UserMessageRow.kt new file mode 100644 index 00000000..279d3873 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/views/UserMessageRow.kt @@ -0,0 +1,107 @@ +package com.itsaky.androidide.plugins.aicore.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup + +/** + * A user message: its bubble at the end edge and its actions beside the bubble, on the start side + * and level with its last line, so a short prompt costs one row of the transcript instead of two. + * When the actions do not fit beside the bubble they go under it, end-aligned. + * + * Holds exactly two children, the bubble and then the actions. Written as a ViewGroup because the + * choice depends on both measured widths, and library `app:` attributes such as Flexbox's are + * read against the host's ids in a plugin layout, so they are ignored. + */ +class UserMessageRow @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : ViewGroup(context, attrs) { + + /** Whether the last measure put the actions beside the bubble rather than under it. */ + private var actionsBeside = false + + private val bubble: View get() = getChildAt(0) + private val actions: View get() = getChildAt(1) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + check(childCount == 2) { "UserMessageRow holds the bubble and its actions, found $childCount views" } + val width = MeasureSpec.getSize(widthMeasureSpec) + + measureChildWithMargins(bubble, widthMeasureSpec, 0, heightMeasureSpec, 0) + val bubbleHeight = bubble.measuredHeight + bubble.verticalMargins + + val contentHeight = if (actions.visibility == GONE) { + actionsBeside = false + bubbleHeight + } else { + measureChildWithMargins(actions, widthMeasureSpec, 0, heightMeasureSpec, 0) + val besideWidth = bubble.measuredWidth + bubble.horizontalMargins + + actions.measuredWidth + actions.horizontalMargins + actionsBeside = besideWidth <= width - paddingLeft - paddingRight + if (actionsBeside) { + maxOf(bubbleHeight, actions.measuredHeight) + } else { + bubbleHeight + actions.verticalMargins + actions.measuredHeight + } + } + setMeasuredDimension( + resolveSize(width, widthMeasureSpec), + resolveSize(contentHeight + paddingTop + paddingBottom, heightMeasureSpec), + ) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + val rtl = layoutDirection == LAYOUT_DIRECTION_RTL + val bubbleParams = bubble.layoutParams as MarginLayoutParams + // Beside, the bubble and the actions share one bottom line, whichever of them is taller. + val bubbleBottom = if (actionsBeside) { + height - paddingBottom - bubbleParams.bottomMargin + } else { + paddingTop + bubbleParams.topMargin + bubble.measuredHeight + } + val bubbleLeft = if (rtl) { + paddingLeft + bubbleParams.leftMargin + } else { + width - paddingRight - bubbleParams.rightMargin - bubble.measuredWidth + } + bubble.layout(bubbleLeft, bubbleBottom - bubble.measuredHeight, bubbleLeft + bubble.measuredWidth, bubbleBottom) + + if (actions.visibility == GONE) return + val actionsParams = actions.layoutParams as MarginLayoutParams + val actionsTop: Int + val actionsLeft: Int + if (actionsBeside) { + actionsTop = bubbleBottom - actions.measuredHeight + actionsLeft = if (rtl) { + bubble.right + bubbleParams.rightMargin + actionsParams.leftMargin + } else { + bubble.left - bubbleParams.leftMargin - actionsParams.rightMargin - actions.measuredWidth + } + } else { + actionsTop = bubbleBottom + bubbleParams.bottomMargin + actionsParams.topMargin + actionsLeft = if (rtl) { + paddingLeft + actionsParams.leftMargin + } else { + width - paddingRight - actionsParams.rightMargin - actions.measuredWidth + } + } + actions.layout(actionsLeft, actionsTop, actionsLeft + actions.measuredWidth, actionsTop + actions.measuredHeight) + } + + override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams = MarginLayoutParams(context, attrs) + + override fun generateDefaultLayoutParams(): LayoutParams = + MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + + override fun generateLayoutParams(p: LayoutParams): LayoutParams = MarginLayoutParams(p) + + override fun checkLayoutParams(p: LayoutParams?): Boolean = p is MarginLayoutParams + + private val View.horizontalMargins: Int + get() = (layoutParams as MarginLayoutParams).let { it.leftMargin + it.rightMargin } + + private val View.verticalMargins: Int + get() = (layoutParams as MarginLayoutParams).let { it.topMargin + it.bottomMargin } +} diff --git a/plugins/AI-Core/src/main/res/drawable/ic_close_fullscreen.xml b/plugins/AI-Core/src/main/res/drawable/ic_close_fullscreen.xml new file mode 100644 index 00000000..efede5fe --- /dev/null +++ b/plugins/AI-Core/src/main/res/drawable/ic_close_fullscreen.xml @@ -0,0 +1,9 @@ + + + diff --git a/plugins/AI-Core/src/main/res/drawable/ic_open_in_full.xml b/plugins/AI-Core/src/main/res/drawable/ic_open_in_full.xml new file mode 100644 index 00000000..f7f9df73 --- /dev/null +++ b/plugins/AI-Core/src/main/res/drawable/ic_open_in_full.xml @@ -0,0 +1,9 @@ + + + diff --git a/plugins/AI-Core/src/main/res/layout/fragment_chat.xml b/plugins/AI-Core/src/main/res/layout/fragment_chat.xml index 69e8267a..28ea5ae7 100644 --- a/plugins/AI-Core/src/main/res/layout/fragment_chat.xml +++ b/plugins/AI-Core/src/main/res/layout/fragment_chat.xml @@ -57,6 +57,18 @@ android:textSize="@dimen/chat_title_text_size" android:textStyle="bold" /> + + + - + android:layout_height="wrap_content" /> - + diff --git a/plugins/AI-Core/src/main/res/layout/view_chat_message_actions.xml b/plugins/AI-Core/src/main/res/layout/view_chat_message_actions.xml index 426cbfb0..22456bc2 100644 --- a/plugins/AI-Core/src/main/res/layout/view_chat_message_actions.xml +++ b/plugins/AI-Core/src/main/res/layout/view_chat_message_actions.xml @@ -1,7 +1,7 @@ Version %1$d of %2$d Chats, settings and a new chat + Show the chat full screen + Back to the editor Settings @@ -186,6 +188,7 @@ Agent AI backend, model and API key Couldn\'t open Agent settings. Open them from Preferences → Configuration → Agent. + Couldn\'t open the chat full screen. MCP servers Couldn\'t open %1$s. Open it from Preferences → Configuration → %1$s. From 9d5f7273d875299cc3738e524da5c6633c933f23 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 2 Nov 2026 17:39:15 +0100 Subject: [PATCH 2/3] ADFA-6767 | Wait for the run's generating flag in the prompt-edit tests A run sets the agent Idle and clears its generating flag a moment later, on another thread. The tests waited for Idle alone, so the next send, edit or version switch could still be refused as busy and the test failed intermittently. They now also wait for canChangePrompts, which turns true only once that flag is clear. --- .../viewmodel/ChatViewModelPromptEditTest.kt | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelPromptEditTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelPromptEditTest.kt index c3882c1d..6728c746 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelPromptEditTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelPromptEditTest.kt @@ -163,14 +163,14 @@ class ChatViewModelPromptEditTest { } assertTrue(viewModel.sendMessage("build teh app")) val prompt = awaitUserMessage(viewModel, "build teh app") - awaitCondition { viewModel.history.value.size == 2 && viewModel.agentState.value == AgentState.Idle } + awaitCondition { viewModel.history.value.size == 2 && viewModel.runFinished() } val result = viewModel.editPrompt(prompt, "build the app") assertEquals(ChatViewModel.EditResult.STARTED, result) awaitCondition { viewModel.history.value.firstOrNull()?.content == "build the app" && - viewModel.agentState.value == AgentState.Idle + viewModel.runFinished() } assertEquals(listOf(Role.USER, Role.ASSISTANT), viewModel.history.value.map { it.role }) assertEquals( @@ -191,7 +191,7 @@ class ChatViewModelPromptEditTest { assertEquals(ChatViewModel.EditResult.STARTED, result) awaitCondition { - viewModel.history.value.size == 2 && viewModel.agentState.value == AgentState.Idle + viewModel.history.value.size == 2 && viewModel.runFinished() } assertEquals( listOf("build the release app"), @@ -211,7 +211,7 @@ class ChatViewModelPromptEditTest { sendAndAwait(viewModel, "now run the tests") viewModel.editPrompt(first, "build the release app") awaitCondition { - viewModel.history.value.size == 2 && viewModel.agentState.value == AgentState.Idle + viewModel.history.value.size == 2 && viewModel.runFinished() } val edited = viewModel.messages.value.first { it.sender == Sender.USER }.id @@ -235,7 +235,7 @@ class ChatViewModelPromptEditTest { sendAndAwait(viewModel, "now run the tests") viewModel.editPrompt(first, "build the release app") awaitCondition { - viewModel.history.value.size == 2 && viewModel.agentState.value == AgentState.Idle + viewModel.history.value.size == 2 && viewModel.runFinished() } viewModel.persistState() awaitCondition { "now run the tests" in stored.getValue(SESSIONS_KEY) } @@ -255,7 +255,7 @@ class ChatViewModelPromptEditTest { val first = sendAndAwait(viewModel, "build the app") sendAndAwait(viewModel, "now run the tests") viewModel.editPrompt(first, "build the release app") - awaitCondition { viewModel.agentState.value == AgentState.Idle && viewModel.history.value.size == 2 } + awaitCondition { viewModel.runFinished() && viewModel.history.value.size == 2 } viewModel.clearMessages() @@ -291,7 +291,7 @@ class ChatViewModelPromptEditTest { composer.cancel() val edited = viewModel.messages.value.single { it.sender == Sender.USER } assertEquals(listOf(File("/project/Edited.kt").absolutePath), edited.contextFiles) - awaitCondition { viewModel.agentState.value == AgentState.Idle } + awaitCondition { viewModel.runFinished() } } @Test @@ -302,11 +302,11 @@ class ChatViewModelPromptEditTest { val first = sendAndAwait(viewModel, "build the app") sendAndAwait(viewModel, "now run the tests") viewModel.editPrompt(first, "build the release app") - awaitCondition { viewModel.agentState.value == AgentState.Idle && viewModel.history.value.size == 2 } + awaitCondition { viewModel.runFinished() && viewModel.history.value.size == 2 } val version = viewModel.messages.value.single { it.sender == Sender.USER }.id assertEquals(ChatViewModel.EditResult.STARTED, viewModel.editPrompt(version, "build the debug app")) - awaitCondition { viewModel.agentState.value == AgentState.Idle && viewModel.history.value.size == 2 } + awaitCondition { viewModel.runFinished() && viewModel.history.value.size == 2 } val replacement = viewModel.messages.value.single { it.sender == Sender.USER } assertEquals("build the debug app", replacement.text) @@ -325,7 +325,7 @@ class ChatViewModelPromptEditTest { val first = sendAndAwait(viewModel, "build the app") sendAndAwait(viewModel, "now run the tests") viewModel.editPrompt(first, "build the release app") - awaitCondition { viewModel.agentState.value == AgentState.Idle && viewModel.history.value.size == 2 } + awaitCondition { viewModel.runFinished() && viewModel.history.value.size == 2 } val edited = viewModel.messages.value.single { it.sender == Sender.USER }.id assertFalse(viewModel.switchPromptVersion(edited, 1)) @@ -377,7 +377,7 @@ class ChatViewModelPromptEditTest { assertTrue(viewModel.sendMessage(text)) val id = awaitUserMessage(viewModel, text) awaitCondition { - viewModel.history.value.size == before + 2 && viewModel.agentState.value == AgentState.Idle + viewModel.history.value.size == before + 2 && viewModel.runFinished() } return id } @@ -427,6 +427,9 @@ class ChatViewModelPromptEditTest { } } + private fun ChatViewModel.runFinished(): Boolean = + agentState.value == AgentState.Idle && canChangePrompts.value + private fun seedTwoExchanges() { stored[SESSIONS_KEY] = """[{"id":"s1","createdAt":1000,"projectKey":"$TEST_PROJECT_KEY","messages":[""" + From 53e04542bc022dd6b5ca7b89d8963012cb07cc18 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 2 Nov 2026 17:39:15 +0100 Subject: [PATCH 3/3] ADFA-6767 | Share the composer and edit between the sheet and full-screen chat Each ChatFragment kept its own attached files, draft and edit state, so the bottom-sheet chat and the full-screen one drifted apart: chips for files no longer sent, a draft lost when full screen closed, and an edit that full screen showed as plain chips. ChatViewModel now owns the attached files, the composer draft and the edit in progress as StateFlows, and both fragments render them. An edit is still restored from saved state after a killed process, but only when the ViewModel is new. A double tap on the full-screen button opens one screen. --- .../plugins/aicore/fragments/ChatFragment.kt | 185 +++++++----------- .../plugins/aicore/viewmodel/ChatViewModel.kt | 87 +++++++- .../ChatViewModelComposerStateTest.kt | 89 +++++++++ 3 files changed, 243 insertions(+), 118 deletions(-) create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelComposerStateTest.kt diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt index 2e18defb..ddadda9f 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt @@ -18,6 +18,7 @@ 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.lifecycleScope @@ -86,11 +87,13 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { const val STATE_DRAFT_BEFORE_EDIT = "draft_before_edit" const val STATE_FILES_BEFORE_EDIT = "files_before_edit" const val STATE_EDIT_FILES = "edit_files" + const val STATE_VIEW_MODEL_TOKEN = "view_model_token" } /** * Whether this is the chat shown over the whole screen rather than the one in the bottom - * sheet. Both show the same conversation, since the ViewModel belongs to the plugin. + * sheet. Both show the same conversation, composer and edit, since the ViewModel belongs to + * the plugin and holds them. */ protected open val isFullScreen: Boolean = false @@ -100,7 +103,9 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { private lateinit var viewModel: ChatViewModel private lateinit var chatAdapter: ChatAdapter private lateinit var markwon: Markwon - private val contextFiles = mutableListOf() + + /** Set from the full-screen tap until this chat stops, so a double tap opens one screen. */ + private var fullScreenLaunchPending = false private var composer: ComposerAutoHideController? = null private var sidebar: ChatSidebarController? = null @@ -142,16 +147,6 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { if (uri != null) readImport(uri) } - /** - * The sent prompt loaded back into the composer for editing, or null outside edit mode. Send - * then replaces that prompt instead of adding a new one. - */ - private var editingMessageId: String? = null - - /** What the composer held before Edit borrowed it, put back when the edit ends either way. */ - private var draftBeforeEdit: String = "" - private var filesBeforeEdit: List = emptyList() - /** The message list's layout-declared padding, before any cutout inset is added. */ private val basePadding = Rect() @@ -221,11 +216,6 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { if (::chatAdapter.isInitialized) { chatAdapter.stopAllAnimations() } - // The composer goes with the view, so the next prompt must carry the draft's files again. - if (editingMessageId != null) viewModel.setContextFiles(filesBeforeEdit) - editingMessageId = null - draftBeforeEdit = "" - filesBeforeEdit = emptyList() super.onDestroyView() // runInFlight=true here, followed by that run still reporting, is the tab-switch fix // working; the run being gone from the trace after it is the bug coming back. Guarded @@ -252,6 +242,7 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { */ override fun onStop() { super.onStop() + fullScreenLaunchPending = false if (::viewModel.isInitialized) { viewModel.persistState() } @@ -263,12 +254,14 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { composer?.saveState(outState) // The same holds while the export picker is up, which is exactly when the IDE is backgrounded. outState.putString(STATE_PENDING_EXPORT, pendingExportSessionId) - // A tab switch rebuilds this fragment and restores the edited text, so edit mode must follow. - val editingId = editingMessageId ?: return - outState.putString(STATE_EDITING_ID, editingId) - outState.putString(STATE_DRAFT_BEFORE_EDIT, draftBeforeEdit) - outState.putStringArrayList(STATE_FILES_BEFORE_EDIT, ArrayList(filesBeforeEdit.map { it.path })) - outState.putStringArrayList(STATE_EDIT_FILES, ArrayList(contextFiles.map { it.path })) + // The ViewModel keeps an edit across tab switches, but not across a killed process. + if (!::viewModel.isInitialized) return + val edit = viewModel.editInProgress.value ?: return + outState.putString(STATE_VIEW_MODEL_TOKEN, viewModel.instanceToken) + outState.putString(STATE_EDITING_ID, edit.messageId) + outState.putString(STATE_DRAFT_BEFORE_EDIT, edit.draftBefore) + outState.putStringArrayList(STATE_FILES_BEFORE_EDIT, ArrayList(edit.filesBefore.map { it.path })) + outState.putStringArrayList(STATE_EDIT_FILES, ArrayList(viewModel.contextFiles.value.map { it.path })) } override fun onViewStateRestored(savedInstanceState: Bundle?) { @@ -277,22 +270,25 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { } /** - * Re-enters the edit a tab switch interrupted, keeping the edited text the view just restored; - * ends it instead, draft back in place, when that prompt is gone or can no longer change. + * Re-enters the edit a killed process interrupted, keeping the edited text the view just + * restored; ends it instead, draft back in place, when that prompt is gone or can no longer + * change. A ViewModel that outlived the saved state is already right, edit ended or not. */ private fun restoreEdit(state: Bundle) { + if (state.getString(STATE_VIEW_MODEL_TOKEN) == viewModel.instanceToken) return val id = state.getString(STATE_EDITING_ID) ?: return - val edited = binding.promptInputEdittext.text?.toString().orEmpty() - val editFiles = state.getStringArrayList(STATE_EDIT_FILES).orEmpty().map(::File) - editingMessageId = id - draftBeforeEdit = state.getString(STATE_DRAFT_BEFORE_EDIT).orEmpty() - filesBeforeEdit = state.getStringArrayList(STATE_FILES_BEFORE_EDIT).orEmpty().map(::File) + val edit = ChatViewModel.EditInProgress( + messageId = id, + draftBefore = state.getString(STATE_DRAFT_BEFORE_EDIT).orEmpty(), + filesBefore = state.getStringArrayList(STATE_FILES_BEFORE_EDIT).orEmpty().map(::File), + ) val message = viewModel.messages.value.firstOrNull { it.id == id } - if (message == null || !viewModel.canChangePrompts.value) return endEdit() - beginEdit(message) - binding.promptInputEdittext.setText(edited) - binding.promptInputEdittext.setSelection(edited.length) - replaceContextFiles(editFiles) + if (message == null || !viewModel.canChangePrompts.value) { + viewModel.setComposerDraft(edit.draftBefore) + viewModel.setContextFiles(edit.filesBefore) + return + } + viewModel.restoreEdit(edit, state.getStringArrayList(STATE_EDIT_FILES).orEmpty().map(::File)) } override fun onCreate(savedInstanceState: Bundle?) { @@ -311,7 +307,6 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { setupFullScreenToggle() setupRecyclerView() setupInputArea() - restoreContextChips() setupCutoutPadding() setupComposer(savedInstanceState) setupStatusBar() @@ -323,7 +318,7 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { "chat view created runInFlight=${viewModel.agentState.value.isRunning} " + "state=${viewModel.agentState.value.traceLabel} " + "messages=${viewModel.messages.value.size} " + - "contextFiles=${contextFiles.size} " + + "contextFiles=${viewModel.contextFiles.value.size} " + "pendingApproval=${viewModel.pendingApprovalRequest.value?.toolName}" ) @@ -613,11 +608,12 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { binding.root, ) binding.promptInputEdittext.bindShortcuts(ChatShortcuts.SEND_MESSAGE runs ::sendPrompt) + binding.promptInputEdittext.doAfterTextChanged { viewModel.setComposerDraft(it?.toString().orEmpty()) } binding.btnAddContext.setOnClickListener { showFilePicker() } - binding.btnCancelEdit.setOnClickListener { endEdit() } + binding.btnCancelEdit.setOnClickListener { viewModel.endEdit() } // Anchored on the bar, not promptInputEdittext: long-press there is the paste menu. wireTooltip(binding.btnAddContext, AiCorePlugin.TOOLTIP_TAG_CONTEXT_FILES) @@ -636,8 +632,8 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { private fun sendPrompt() { val message = binding.promptInputEdittext.text?.toString() ?: return if (message.isBlank()) return - editingMessageId?.let { id -> - sendEdit(id, message) + viewModel.editInProgress.value?.let { edit -> + sendEdit(edit.messageId, message) return } if (!viewModel.sendMessage(message)) return @@ -653,11 +649,11 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { when (viewModel.editPrompt(messageId, text)) { ChatViewModel.EditResult.STARTED -> { composer?.hideKeyboard() - endEdit() + viewModel.endEdit() } ChatViewModel.EditResult.BUSY -> showInfoSnackbar(getString(R.string.msg_edit_stop_first)) ChatViewModel.EditResult.NOT_EDITABLE -> { - endEdit() + viewModel.endEdit() showInfoSnackbar(getString(R.string.msg_edit_unavailable)) } ChatViewModel.EditResult.REFUSED -> Unit @@ -669,44 +665,34 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { * until Send; the draft the composer held is kept aside and comes back when the edit ends. */ private fun beginEdit(message: ChatMessage) { - val binding = _binding ?: return if (!viewModel.canChangePrompts.value) { showInfoSnackbar(getString(R.string.msg_edit_stop_first)) return } - // Moving the edit to another prompt keeps the draft from before the first one. - if (editingMessageId == null) { - draftBeforeEdit = binding.promptInputEdittext.text?.toString().orEmpty() - filesBeforeEdit = contextFiles.toList() - } - editingMessageId = message.id - chatAdapter.editingMessageId = message.id AgentTrace.detail("UI", "edit started files=${message.contextFiles?.size ?: 0}") - // Files deleted since the prompt was sent are left out rather than attached as empty. - replaceContextFiles(message.contextFiles.orEmpty().map(::File).filter { it.isFile }) - binding.promptInputEdittext.setText(message.text) - binding.promptInputEdittext.setSelection(binding.promptInputEdittext.length()) + viewModel.beginEdit(message) + composer?.focusInput() + } + + /** Shows [edit]'s banner and marks its prompt, or clears both outside edit mode. */ + private fun renderEdit(edit: ChatViewModel.EditInProgress?) { + val binding = _binding ?: return + chatAdapter.editingMessageId = edit?.messageId + binding.editBanner.isVisible = edit != null + if (edit == null) return // Only an older prompt forks, so only there does Send keep the original. binding.editBannerText.setText( - if (viewModel.editForks(message.id)) R.string.chat_editing_message_fork + if (viewModel.editForks(edit.messageId)) R.string.chat_editing_message_fork else R.string.chat_editing_message ) - binding.editBanner.isVisible = true - composer?.focusInput() } - /** Leaves edit mode, putting back the draft and files the composer held before it began. */ - private fun endEdit() { - if (editingMessageId == null) return - editingMessageId = null - if (::chatAdapter.isInitialized) chatAdapter.editingMessageId = null - val binding = _binding ?: return - binding.editBanner.isVisible = false - binding.promptInputEdittext.setText(draftBeforeEdit) - binding.promptInputEdittext.setSelection(binding.promptInputEdittext.length()) - replaceContextFiles(filesBeforeEdit) - draftBeforeEdit = "" - filesBeforeEdit = emptyList() + /** Puts [draft] in the composer unless it already holds it, so typing keeps its caret. */ + private fun renderComposerDraft(draft: String) { + val input = _binding?.promptInputEdittext ?: return + if (input.text?.toString() == draft) return + input.setText(draft) + input.setSelection(draft.length) } /** @@ -803,6 +789,9 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { launch { observePendingApprovalRequest() } launch { observeEditableMessage() } launch { viewModel.retryableErrorIds.collect { chatAdapter.retryableErrorIds = it } } + launch { viewModel.contextFiles.collect(::renderContextChips) } + launch { viewModel.composerDraft.collect(::renderComposerDraft) } + launch { viewModel.editInProgress.collect(::renderEdit) } } } } @@ -846,8 +835,8 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { ).collect { (canChange, versions, messages) -> chatAdapter.canChangePrompts = canChange chatAdapter.promptVersions = versions - val editing = editingMessageId ?: return@collect - if (messages.none { it.id == editing }) endEdit() + val editing = viewModel.editInProgress.value?.messageId ?: return@collect + if (messages.none { it.id == editing }) viewModel.endEdit() } } @@ -968,54 +957,25 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { } val dialog = FilePickerDialogFragment.newInstance(startPath) { files -> - addContextFiles(files) + viewModel.addContextFiles(files) } composer?.pauseUntilClosed(dialog.lifecycle) dialog.show(parentFragmentManager, "file_picker") } - /** - * Rebuilds the attached-file chips from the ViewModel. It keeps the attachments across a tab - * switch, so without this they would still go into the next prompt with nothing on screen - * naming them. - */ - private fun restoreContextChips() { - contextFiles.clear() - contextFiles.addAll(viewModel.contextFiles) - contextFiles.forEach(::addChipForFile) - } - - /** Swaps every attached-file chip for [files], and hands the ViewModel the same list. */ - private fun replaceContextFiles(files: List) { + /** Shows one chip per attached file; closing a chip detaches its file. */ + private fun renderContextChips(files: List) { val binding = _binding ?: return - contextFiles.clear() binding.contextChipGroup.removeAllViews() - updateContextChipVisibility() - addContextFiles(files) - } - - private fun addContextFiles(files: List) { files.forEach { file -> - if (!contextFiles.contains(file)) { - contextFiles.add(file) - addChipForFile(file) - } - } - viewModel.setContextFiles(contextFiles) - } - - private fun addChipForFile(file: File) { - val chip = Chip(requireContext()).apply { - text = file.name - isCloseIconVisible = true - setOnCloseIconClickListener { - contextFiles.remove(file) - binding.contextChipGroup.removeView(this) - viewModel.setContextFiles(contextFiles) - updateContextChipVisibility() - } + binding.contextChipGroup.addView( + Chip(requireContext()).apply { + text = file.name + isCloseIconVisible = true + setOnCloseIconClickListener { viewModel.removeContextFile(file) } + } + ) } - binding.contextChipGroup.addView(chip) updateContextChipVisibility() } @@ -1031,7 +991,7 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { when (action) { ChatAdapter.ACTION_RETRY -> { // First, so the retried prompt carries the draft's files and not the edit's. - endEdit() + viewModel.endEdit() // The prompt behind this row, not its text: the row may be a tool failure. viewModel.retryLastRun() } @@ -1092,6 +1052,8 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { /** Opens [FullScreenChatFragment] in the host's full-screen surface. */ private fun openFullScreen() { + if (fullScreenLaunchPending) return + fullScreenLaunchPending = true val opened = PluginFragmentHelper.getServiceRegistry(AiCorePlugin.PLUGIN_ID) ?.get(IdeUIService::class.java) ?.openPluginScreen( @@ -1100,6 +1062,7 @@ open class ChatFragment : Fragment(), ApprovalDialogFragment.Host { binding.chatTitle.text.toString() ) ?: false if (!opened) { + fullScreenLaunchPending = false AiCorePlugin.getContext()?.logger ?.warn("ChatFragment: could not open the chat full screen") showInfoSnackbar(getString(R.string.msg_full_screen_unavailable)) diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt index 87387e90..54b91a15 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt @@ -75,6 +75,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.future.await import kotlinx.coroutines.isActive import kotlinx.coroutines.job @@ -428,10 +429,36 @@ class ChatViewModel( private fun isBusy(): Boolean = !isIdle(isGenerating.value, _agentState.value, pendingApprovalRequest.value) - private var _contextFiles = listOf() + private val _contextFiles = MutableStateFlow>(emptyList()) - /** Files the user attached; read back by the fragment to rebuild its chips on re-attach. */ - val contextFiles: List get() = _contextFiles + /** Files attached to the next prompt, shown as chips by every chat screen. */ + val contextFiles: StateFlow> = _contextFiles.asStateFlow() + + private val _composerDraft = MutableStateFlow("") + + /** The text in the composer, shared by the bottom-sheet chat and the full-screen one. */ + val composerDraft: StateFlow = _composerDraft.asStateFlow() + + /** + * A sent prompt loaded back into the composer for editing. + * + * @property messageId the prompt Send replaces or forks. + * @property draftBefore the composer text put back when the edit ends. + * @property filesBefore the attached files put back when the edit ends. + */ + data class EditInProgress( + val messageId: String, + val draftBefore: String, + val filesBefore: List, + ) + + private val _editInProgress = MutableStateFlow(null) + + /** The edit every chat screen shows, or null outside edit mode. */ + val editInProgress: StateFlow = _editInProgress.asStateFlow() + + /** Unique to this instance, so saved UI state can tell whether this ViewModel outlived it. */ + val instanceToken: String = UUID.randomUUID().toString() private var stateUpdateJob: Job? = null @@ -642,8 +669,54 @@ class ChatViewModel( * Set context files to include in prompts. */ fun setContextFiles(files: List) { - // Copied: the caller passes its own mutable list, which it keeps editing. - _contextFiles = files.toList() + _contextFiles.value = files.distinct() + } + + /** Attaches [files] to the next prompt, skipping any already attached. */ + fun addContextFiles(files: List) { + _contextFiles.update { (it + files).distinct() } + } + + /** Detaches [file] from the next prompt. */ + fun removeContextFile(file: File) { + _contextFiles.update { it - file } + } + + /** Records [text] as what the composer holds. */ + fun setComposerDraft(text: String) { + _composerDraft.value = text + } + + /** + * Loads [message] and its files into the composer for editing. Moving an edit to another + * prompt keeps the draft and files from before the first one. + */ + fun beginEdit(message: ChatMessage) { + val previous = _editInProgress.value + _editInProgress.value = EditInProgress( + messageId = message.id, + draftBefore = previous?.draftBefore ?: _composerDraft.value, + filesBefore = previous?.filesBefore ?: _contextFiles.value, + ) + _contextFiles.value = message.contextFiles.orEmpty().map(::File).filter { it.isFile } + _composerDraft.value = message.text + } + + /** Leaves edit mode, putting back the draft and files the composer held before it began. */ + fun endEdit() { + val edit = _editInProgress.value ?: return + _editInProgress.value = null + _composerDraft.value = edit.draftBefore + _contextFiles.value = edit.filesBefore + } + + /** + * Re-enters [edit] after the process was killed, with [editFiles] attached; the composer + * restores the edited text itself. + */ + fun restoreEdit(edit: EditInProgress, editFiles: List) { + _editInProgress.value = edit + _contextFiles.value = editFiles } /** @@ -966,7 +1039,7 @@ class ChatViewModel( // Reject re-entry while a generation is still in flight. if (!isGenerating.compareAndSet(false, true)) return false // An overflow's turns are still in the history, and the estimate that let it through missed. - launchRun(llmService, userMessage, contextFiles, forceCompaction = lastRunOverflowed) + launchRun(llmService, userMessage, contextFiles.value, forceCompaction = lastRunOverflowed) return true } @@ -1519,7 +1592,7 @@ class ChatViewModel( return EditResult.NOT_EDITABLE } // Read before the cut: the composer leaves edit mode on it, handing back the draft's files. - val runFiles = contextFiles + val runFiles = contextFiles.value // Checked before the rewind, so a refused send changes nothing. val llmService = passPreflight(newText) ?: return EditResult.REFUSED // Claimed before the cut too, so a chat is never cut for a run that then cannot start. diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelComposerStateTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelComposerStateTest.kt new file mode 100644 index 00000000..5bf83c7d --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelComposerStateTest.kt @@ -0,0 +1,89 @@ +package com.itsaky.androidide.plugins.aicore.viewmodel + +import com.itsaky.androidide.plugins.aicore.models.ChatMessage +import com.itsaky.androidide.plugins.aicore.models.Sender +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * The composer's text, its attached files and an edit in progress live in the ViewModel, so the + * bottom-sheet chat and the full-screen one show the same composer and the same edit. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatViewModelComposerStateTest { + + @Before + fun setUp() { + // ChatViewModel's stateIn() calls run on viewModelScope, i.e. Dispatchers.Main. + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun givenADraftAndFiles_whenEditingAPrompt_thenTheEditIsSharedAndEndingItPutsTheDraftBack() { + val viewModel = ChatViewModel { null } + val attached = File.createTempFile("Draft", ".kt").apply { deleteOnExit() } + val promptFile = File.createTempFile("Prompt", ".kt").apply { deleteOnExit() } + viewModel.setComposerDraft("half-typed") + viewModel.setContextFiles(listOf(attached)) + + viewModel.beginEdit(prompt("u2", "second prompt", promptFile)) + + assertEquals("u2", viewModel.editInProgress.value?.messageId) + assertEquals("second prompt", viewModel.composerDraft.value) + assertEquals(listOf(File(promptFile.absolutePath)), viewModel.contextFiles.value) + + viewModel.endEdit() + + assertNull(viewModel.editInProgress.value) + assertEquals("half-typed", viewModel.composerDraft.value) + assertEquals(listOf(attached), viewModel.contextFiles.value) + } + + @Test + fun givenAnEdit_whenItMovesToAnotherPrompt_thenEndingItPutsBackTheDraftFromBeforeTheFirst() { + val viewModel = ChatViewModel { null } + viewModel.setComposerDraft("half-typed") + + viewModel.beginEdit(prompt("u2", "second prompt")) + viewModel.beginEdit(prompt("u1", "first prompt")) + + assertEquals("first prompt", viewModel.composerDraft.value) + viewModel.endEdit() + assertEquals("half-typed", viewModel.composerDraft.value) + } + + @Test + fun givenAttachedFiles_whenAttachingAndDetaching_thenEachFileIsAttachedOnce() { + val viewModel = ChatViewModel { null } + val a = File("/project/A.kt") + val b = File("/project/B.kt") + + viewModel.addContextFiles(listOf(a, b, a)) + viewModel.addContextFiles(listOf(b)) + assertEquals(listOf(a, b), viewModel.contextFiles.value) + + viewModel.removeContextFile(a) + assertEquals(listOf(b), viewModel.contextFiles.value) + } + + private fun prompt(id: String, text: String, vararg files: File) = ChatMessage( + id = id, + text = text, + sender = Sender.USER, + contextFiles = files.map { it.absolutePath }.takeIf { it.isNotEmpty() }, + ) +}