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..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 @@ -72,7 +73,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. */ @@ -86,15 +87,25 @@ 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, composer and edit, since the ViewModel belongs to + * the plugin and holds them. + */ + protected open val isFullScreen: Boolean = false + private var _binding: FragmentChatBinding? = null private val binding get() = _binding!! 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 @@ -136,16 +147,6 @@ 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() @@ -215,11 +216,6 @@ 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 @@ -246,6 +242,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { */ override fun onStop() { super.onStop() + fullScreenLaunchPending = false if (::viewModel.isInitialized) { viewModel.persistState() } @@ -257,12 +254,14 @@ 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?) { @@ -271,22 +270,25 @@ 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?) { @@ -302,9 +304,9 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { syncStorageToCurrentProject() setupChatTitle() setupSidebar() + setupFullScreenToggle() setupRecyclerView() setupInputArea() - restoreContextChips() setupCutoutPadding() setupComposer(savedInstanceState) setupStatusBar() @@ -316,7 +318,7 @@ 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}" ) @@ -606,11 +608,12 @@ 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) @@ -629,8 +632,8 @@ 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 @@ -646,11 +649,11 @@ 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 @@ -662,44 +665,34 @@ 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) } /** @@ -796,6 +789,9 @@ 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) } } } } @@ -839,8 +835,8 @@ 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() } } @@ -961,54 +957,25 @@ 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() } @@ -1024,7 +991,7 @@ 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() } @@ -1066,6 +1033,42 @@ 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() { + if (fullScreenLaunchPending) return + fullScreenLaunchPending = true + 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) { + fullScreenLaunchPending = false + 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/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/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. 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() }, + ) +} 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":[""" +