diff --git a/plugins/AI-Core/build.gradle.kts b/plugins/AI-Core/build.gradle.kts
index fa67f6cf..fa98f596 100644
--- a/plugins/AI-Core/build.gradle.kts
+++ b/plugins/AI-Core/build.gradle.kts
@@ -85,6 +85,8 @@ dependencies {
testImplementation(files("../../libs/plugin-api.jar"))
// plugin-api's prompt loader parses YAML with the host's copy; JVM tests need their own, same version
testImplementation("org.snakeyaml:snakeyaml-engine:2.10")
+ // plugin-api's PromptConfigStore logs through the host's slf4j; JVM tests need their own, same version
+ testRuntimeOnly("org.slf4j:slf4j-api:2.0.12")
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.8")
testImplementation("org.json:json:20240303")
diff --git a/plugins/AI-Core/src/main/assets/docs/index.html b/plugins/AI-Core/src/main/assets/docs/index.html
index 80b012b6..54b96536 100644
--- a/plugins/AI-Core/src/main/assets/docs/index.html
+++ b/plugins/AI-Core/src/main/assets/docs/index.html
@@ -262,6 +262,43 @@
Reading the conversation
text. It appears under a reply once the agent has finished writing it.
+ Editing a message you sent
+ Tap the pencil under any of your messages to fix a typo, add a detail you
+ left out, or rephrase a request the agent misread. The message goes back into
+ the message box with the files that were attached to it, under an
+ Editing message banner.
+
+ - On your latest message, Send replaces it: the original and
+ everything after it — the agent's replies and the tools it ran — are removed
+ from the chat and from what the model remembers, then the agent runs on the
+ edited message.
+ - On an earlier message, Send starts a new version of the chat
+ from that point instead, and nothing is removed. The agent is given only the
+ messages above the one you edited.
+ - The × on the banner cancels: the chat is left exactly as it
+ was, and whatever you had typed before tapping the pencil comes back.
+ - Changes the agent already made to your files are not undone. If
+ an earlier run edited or created files, check them, or ask the agent to
+ revert them.
+ - Messages cannot be edited while the agent is working or waiting for your
+ approval. Stop it first.
+
+
+ Versions of a chat
+ A message you edited from further up shows ‹ 2/2 ›
+ underneath. Tap the arrows to switch between its versions; the chat below it
+ changes to what was said in that version.
+
+ - Each version is its own conversation. The agent remembers only the
+ version on screen, and knows nothing of what was said in the others.
+ - Versions can have versions: editing a message inside one adds arrows
+ there too, and switching back returns to the one you last looked at.
+ - Files are not switched. Your project stays as it is whichever
+ version is on screen.
+ - Clear Chat removes every version. Exporting a chat saves the
+ version on screen.
+
+
Keeping several chats
Chats belong to the project they were started in. Opening a project shows
that project's chats and no one else's, and you can keep as many as you want —
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/adapters/ChatAdapter.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/adapters/ChatAdapter.kt
index 2a311873..06f35cfc 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/adapters/ChatAdapter.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/adapters/ChatAdapter.kt
@@ -1,5 +1,7 @@
package com.itsaky.androidide.plugins.aicore.adapters
+import android.animation.ValueAnimator
+import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -19,6 +21,7 @@ import com.itsaky.androidide.plugins.aicore.models.ChatMessage
import com.itsaky.androidide.plugins.aicore.models.MessageStatus
import com.itsaky.androidide.plugins.aicore.models.Sender
import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
+import com.itsaky.androidide.plugins.aicore.viewmodel.ChatBranches
import io.noties.markwon.Markwon
import java.text.DecimalFormat
import java.text.SimpleDateFormat
@@ -48,6 +51,45 @@ class ChatAdapter(
private val decimalSecondsFormatter = DecimalFormat("0.0")
private val expandedMessageIds = mutableSetOf()
private val animatingHolders = mutableSetOf()
+ private val pulsingHolders = mutableSetOf()
+
+ /**
+ * Whether user messages offer Edit and version switching, from ChatViewModel.canChangePrompts;
+ * false while a run is in flight.
+ */
+ var canChangePrompts: Boolean = false
+ set(value) {
+ if (field == value) return
+ field = value
+ notifyUserRowsChanged { true }
+ }
+
+ /** Every user message with other versions, and which of them it is; see ChatBranches. */
+ internal var promptVersions: Map = emptyMap()
+ set(value) {
+ val previous = field
+ if (previous == value) return
+ field = value
+ notifyUserRowsChanged { previous[it] != value[it] }
+ }
+
+ /** The user message loaded into the composer for editing, or null; ChatFragment owns the state. */
+ var editingMessageId: String? = null
+ set(value) {
+ val previous = field
+ if (previous == value) return
+ field = value
+ notifyUserRowsChanged { it == previous || it == value }
+ }
+
+ /** Rebinds the actions of the user rows [changed] picks; a payload keeps their text as it is. */
+ private inline fun notifyUserRowsChanged(changed: (messageId: String) -> Boolean) {
+ currentList.forEachIndexed { position, message ->
+ if (message.sender == Sender.USER && changed(message.id)) {
+ notifyItemChanged(position, PromptActionsPayload)
+ }
+ }
+ }
companion object {
private const val VIEW_TYPE_DEFAULT = 0
@@ -56,7 +98,13 @@ class ChatAdapter(
const val ACTION_RETRY = "retry"
const val ACTION_COPY = "copy"
+ const val ACTION_EDIT = "edit"
+ const val ACTION_PREVIOUS_VERSION = "previous_version"
+ const val ACTION_NEXT_VERSION = "next_version"
const val ACTION_OPEN_SETTINGS = "open_settings"
+
+ /** A version arrow with nowhere to go, or held back while the agent works. */
+ private const val DISABLED_ALPHA = 0.38f
}
sealed class MessageViewHolder(view: View) : RecyclerView.ViewHolder(view)
@@ -72,8 +120,15 @@ class ChatAdapter(
val btnRetry: Button = view.findViewById(R.id.btn_retry)
/** Fold toggle; only the user bubble layout has one. */
val btnToggleExpand: ImageButton? = view.findViewById(R.id.btn_toggle_expand)
+ /** The bordered bubble; only the user bubble layout has one. */
+ val userBubble: View? = view.findViewById(R.id.user_bubble)
val messageActions: LinearLayout = view.findViewById(R.id.message_actions)
val btnCopyMessage: ImageButton = view.findViewById(R.id.btn_copy_message)
+ val btnEditMessage: ImageButton = view.findViewById(R.id.btn_edit_message)
+ val versionNav: LinearLayout = view.findViewById(R.id.version_nav)
+ val btnPreviousVersion: ImageButton = view.findViewById(R.id.btn_previous_version)
+ val versionLabel: TextView = view.findViewById(R.id.version_label)
+ val btnNextVersion: ImageButton = view.findViewById(R.id.btn_next_version)
/**
* Queued next step of the "..." animation, or null when it isn't running. Retained so
@@ -81,6 +136,9 @@ class ChatAdapter(
* would keep this holder, its views and their Context reachable after the row is gone.
*/
var generatingDotsStep: Runnable? = null
+
+ /** Pulses [userBubble]'s border while its prompt is being edited; null otherwise. */
+ var editPulse: ValueAnimator? = null
}
class SystemMessageViewHolder(view: View) : MessageViewHolder(view) {
@@ -151,6 +209,8 @@ class ChatAdapter(
if (payloads.isEmpty()) {
// No payload, do full bind
onBindViewHolder(holder, position)
+ } else if (payloads.all { it === PromptActionsPayload }) {
+ if (holder is DefaultMessageViewHolder) updateMessageActions(holder, getItem(position))
} else {
// Handle payload update
val payload = payloads[0]
@@ -265,11 +325,23 @@ class ChatAdapter(
/** Wired once per holder: a streamed reply grows via payloads, so the tap reads the current item. */
private fun wireMessageActions(holder: DefaultMessageViewHolder) {
- holder.btnCopyMessage.setOnClickListener {
+ holder.btnCopyMessage.sendsAction(holder, ACTION_COPY)
+ wireTooltip(holder.btnCopyMessage, AiCorePlugin.TOOLTIP_TAG_MESSAGE_COPY)
+ holder.btnEditMessage.sendsAction(holder, ACTION_EDIT)
+ wireTooltip(holder.btnEditMessage, AiCorePlugin.TOOLTIP_TAG_MESSAGE_EDIT)
+ holder.btnPreviousVersion.sendsAction(holder, ACTION_PREVIOUS_VERSION)
+ holder.btnNextVersion.sendsAction(holder, ACTION_NEXT_VERSION)
+ wireTooltip(holder.btnPreviousVersion, AiCorePlugin.TOOLTIP_TAG_MESSAGE_VERSIONS)
+ wireTooltip(holder.btnNextVersion, AiCorePlugin.TOOLTIP_TAG_MESSAGE_VERSIONS)
+ wireTooltip(holder.versionLabel, AiCorePlugin.TOOLTIP_TAG_MESSAGE_VERSIONS)
+ }
+
+ /** Taps report [action] on the item [holder] shows now, which a streamed reply may have grown. */
+ private fun View.sendsAction(holder: RecyclerView.ViewHolder, action: String) {
+ setOnClickListener {
val pos = holder.bindingAdapterPosition
- if (pos != RecyclerView.NO_POSITION) onMessageAction(ACTION_COPY, getItem(pos))
+ if (pos != RecyclerView.NO_POSITION) onMessageAction(action, getItem(pos))
}
- wireTooltip(holder.btnCopyMessage, AiCorePlugin.TOOLTIP_TAG_MESSAGE_COPY)
}
/**
@@ -283,6 +355,61 @@ class ChatAdapter(
val copyable = message.sender == Sender.USER || message.sender == Sender.AGENT
val show = copyable && message.status != MessageStatus.LOADING && !streaming
holder.messageActions.visibility = if (show) View.VISIBLE else View.GONE
+ val isUser = message.sender == Sender.USER
+ holder.btnEditMessage.visibility = if (isUser && canChangePrompts) View.VISIBLE else View.GONE
+ // Disabled outright: the prompt is already in the composer, and Cancel carries the tooltip.
+ val editing = isUser && message.id == editingMessageId
+ holder.btnEditMessage.isEnabled = !editing
+ holder.btnEditMessage.alpha = if (editing) DISABLED_ALPHA else 1f
+ if (editing) startEditPulse(holder) else stopEditPulse(holder)
+ val version = promptVersions[message.id]?.takeIf { isUser }
+ holder.versionNav.visibility = if (version != null) View.VISIBLE else View.GONE
+ if (version != null) {
+ val context = holder.versionLabel.context
+ holder.versionLabel.text =
+ context.getString(R.string.message_version_label, version.index + 1, version.count)
+ holder.versionLabel.contentDescription =
+ context.getString(R.string.desc_message_version, version.index + 1, version.count)
+ // Dimmed rather than disabled: a disabled view drops long-press, and with it the tooltip.
+ val canGoOlder = canChangePrompts && version.index > 0
+ val canGoNewer = canChangePrompts && version.index < version.count - 1
+ holder.btnPreviousVersion.alpha = if (canGoOlder) 1f else DISABLED_ALPHA
+ holder.btnNextVersion.alpha = if (canGoNewer) 1f else DISABLED_ALPHA
+ }
+ }
+
+ /**
+ * Fades the bubble's border between its usual grey and the editing blue, and back, until
+ * [stopEditPulse]. Leaves a pulse already running alone, so a rebind doesn't restart it.
+ */
+ private fun startEditPulse(holder: DefaultMessageViewHolder) {
+ val bubble = holder.userBubble ?: return
+ if (holder.editPulse != null) return
+ bubble.setBackgroundResource(R.drawable.bg_user_message_editing)
+ // Mutated, so the pulse recolors this bubble only and not every user of the drawable.
+ val background = bubble.background.mutate() as GradientDrawable
+ val context = bubble.context
+ val strokeWidth = context.resources.getDimensionPixelSize(R.dimen.chat_user_bubble_editing_stroke_width)
+ holder.editPulse = ValueAnimator.ofArgb(
+ context.getColor(R.color.plugin_outline_variant),
+ context.getColor(R.color.plugin_editing),
+ ).apply {
+ duration = context.resources.getInteger(R.integer.chat_user_bubble_editing_pulse_ms).toLong()
+ repeatMode = ValueAnimator.REVERSE
+ repeatCount = ValueAnimator.INFINITE
+ addUpdateListener { background.setStroke(strokeWidth, it.animatedValue as Int) }
+ start()
+ }
+ pulsingHolders.add(holder)
+ }
+
+ /** Cancels the pulse, if any, and gives the bubble its usual border back. */
+ private fun stopEditPulse(holder: DefaultMessageViewHolder) {
+ val pulse = holder.editPulse ?: return
+ pulse.cancel()
+ holder.editPulse = null
+ pulsingHolders.remove(holder)
+ holder.userBubble?.setBackgroundResource(R.drawable.bg_user_message)
}
private fun bindExpandToggle(holder: DefaultMessageViewHolder, toggle: ImageButton, message: ChatMessage) {
@@ -417,18 +544,31 @@ class ChatAdapter(
}
/**
- * Stop every live "…" animation. Call from the host fragment's `onDestroyView`:
+ * Stop every live "…" animation and edit pulse. Call from the host fragment's `onDestroyView`:
* a message still streaming when the tab closes never reaches a terminal status
* and its holder is never recycled, so nothing else cancels its Runnable.
*/
fun stopAllAnimations() {
animatingHolders.toList().forEach { hideGeneratingDots(it) }
+ pulsingHolders.toList().forEach { stopEditPulse(it) }
+ }
+
+ // A row scrolled off but still cached keeps its pulse; it only ticks while on screen.
+ override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
+ super.onViewDetachedFromWindow(holder)
+ (holder as? DefaultMessageViewHolder)?.editPulse?.pause()
+ }
+
+ override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
+ super.onViewAttachedToWindow(holder)
+ (holder as? DefaultMessageViewHolder)?.editPulse?.resume()
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder)
if (holder is DefaultMessageViewHolder) {
hideGeneratingDots(holder)
+ stopEditPulse(holder)
// Here, not in bind: a rebind of the same row would flash its toggle off for a frame.
holder.btnToggleExpand?.visibility = View.GONE
}
@@ -520,4 +660,7 @@ class ChatAdapter(
// Payload for partial updates
data class TextUpdatePayload(val text: String, val status: MessageStatus)
+
+ /** Payload for a user row whose Edit or version controls changed; nothing else about it did. */
+ object PromptActionsPayload
}
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 4b3d594f..a85ac546 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
@@ -35,6 +35,7 @@ import com.itsaky.androidide.plugins.aicore.logging.AgentTrace
import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX
import com.itsaky.androidide.plugins.aicore.managers.ProjectKey
import com.itsaky.androidide.plugins.aicore.models.AgentState
+import com.itsaky.androidide.plugins.aicore.models.ChatMessage
import com.itsaky.androidide.plugins.aicore.models.ChatTranscript
import com.itsaky.androidide.plugins.aicore.models.SessionRow
import com.itsaky.androidide.plugins.aicore.models.isRunning
@@ -77,6 +78,12 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
/** Saved-state key for [pendingExportSessionId], which must outlive the picker's round trip. */
const val STATE_PENDING_EXPORT = "pending_export_session_id"
+
+ /** Saved-state keys for an edit in progress; see [restoreEdit]. */
+ const val STATE_EDITING_ID = "editing_message_id"
+ 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"
}
private var _binding: FragmentChatBinding? = null
@@ -126,6 +133,16 @@ 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()
@@ -195,6 +212,11 @@ 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
@@ -230,6 +252,36 @@ 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 }))
+ }
+
+ override fun onViewStateRestored(savedInstanceState: Bundle?) {
+ super.onViewStateRestored(savedInstanceState)
+ restoreEdit(savedInstanceState ?: return)
+ }
+
+ /**
+ * 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.
+ */
+ private fun restoreEdit(state: Bundle) {
+ 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 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)
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -562,12 +614,14 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
binding.btnAddContext.setOnClickListener {
showFilePicker()
}
+ binding.btnCancelEdit.setOnClickListener { endEdit() }
// Anchored on the bar, not promptInputEdittext: long-press there is the paste menu.
wireTooltip(binding.btnAddContext, AiCorePlugin.TOOLTIP_TAG_CONTEXT_FILES)
wireTooltip(binding.inputBarCard, AiCorePlugin.TOOLTIP_TAG_CHAT_INPUT)
wireTooltip(binding.sendButton, AiCorePlugin.TOOLTIP_TAG_CHAT_SEND)
wireTooltip(binding.backendStatusText, AiCorePlugin.TOOLTIP_TAG_SETTINGS_BACKEND)
+ wireTooltip(binding.btnCancelEdit, AiCorePlugin.TOOLTIP_TAG_MESSAGE_EDIT)
}
/**
@@ -580,11 +634,79 @@ 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)
+ return
+ }
if (!viewModel.sendMessage(message)) return
composer?.hideKeyboard()
binding.promptInputEdittext.text?.clear()
}
+ /**
+ * Confirms an edit: the chat rewinds or forks at [messageId] and runs on [text]. A refused send
+ * stays in edit mode with the text in place, as a refused plain send keeps its prompt.
+ */
+ private fun sendEdit(messageId: String, text: String) {
+ when (viewModel.editPrompt(messageId, text)) {
+ ChatViewModel.EditResult.STARTED -> {
+ composer?.hideKeyboard()
+ endEdit()
+ }
+ ChatViewModel.EditResult.BUSY -> showInfoSnackbar(getString(R.string.msg_edit_stop_first))
+ ChatViewModel.EditResult.NOT_EDITABLE -> {
+ endEdit()
+ showInfoSnackbar(getString(R.string.msg_edit_unavailable))
+ }
+ ChatViewModel.EditResult.REFUSED -> Unit
+ }
+ }
+
+ /**
+ * Loads a sent prompt and the files that went with it into the composer. Nothing is discarded
+ * 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())
+ // 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
+ 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()
+ }
+
/**
* 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.
@@ -670,6 +792,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
launch { observeMessages() }
launch { observeAgentState() }
launch { observePendingApprovalRequest() }
+ launch { observeEditableMessage() }
}
}
}
@@ -700,6 +823,33 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
}
}
+ /**
+ * Keeps Edit and the version arrows in step with whether the agent is idle, and leaves edit
+ * mode once the prompt being edited is gone — a chat switch, a Retry, another version shown.
+ */
+ private suspend fun observeEditableMessage() {
+ combine(
+ viewModel.canChangePrompts,
+ viewModel.promptVersions,
+ viewModel.messages,
+ ::Triple,
+ ).collect { (canChange, versions, messages) ->
+ chatAdapter.canChangePrompts = canChange
+ chatAdapter.promptVersions = versions
+ val editing = editingMessageId ?: return@collect
+ if (messages.none { it.id == editing }) endEdit()
+ }
+ }
+
+ /** Shows the older ([step] -1) or newer (1) version of a prompt, if the agent is idle. */
+ private fun switchVersion(message: ChatMessage, step: Int) {
+ if (!viewModel.canChangePrompts.value) {
+ showInfoSnackbar(getString(R.string.msg_version_stop_first))
+ return
+ }
+ viewModel.switchPromptVersion(message.id, step)
+ }
+
/** True while the newest message is fully visible, i.e. the user is not reading back. */
private fun RecyclerView.isAtBottom(): Boolean = !canScrollVertically(1)
@@ -825,6 +975,15 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
contextFiles.forEach(::addChipForFile)
}
+ /** Swaps every attached-file chip for [files], and hands the ViewModel the same list. */
+ private fun replaceContextFiles(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)) {
@@ -858,9 +1017,11 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
binding.contextChipScroll.isVisible = binding.contextChipGroup.childCount > 0
}
- private fun onMessageAction(action: String, message: com.itsaky.androidide.plugins.aicore.models.ChatMessage) {
+ private fun onMessageAction(action: String, message: ChatMessage) {
when (action) {
ChatAdapter.ACTION_RETRY -> {
+ // First, so the retried prompt carries the draft's files and not the edit's.
+ endEdit()
// The prompt behind this row, not its text: the row may be a tool failure.
viewModel.retryLastRun()
}
@@ -869,6 +1030,9 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
openSettingsFragment()
}
ChatAdapter.ACTION_COPY -> copyToClipboard(message.text)
+ ChatAdapter.ACTION_EDIT -> beginEdit(message)
+ ChatAdapter.ACTION_PREVIOUS_VERSION -> switchVersion(message, -1)
+ ChatAdapter.ACTION_NEXT_VERSION -> switchVersion(message, 1)
}
}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ComposerAutoHideController.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ComposerAutoHideController.kt
index 522ff134..c9859805 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ComposerAutoHideController.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ComposerAutoHideController.kt
@@ -117,6 +117,16 @@ internal class ComposerAutoHideController(
})
}
+ /** Opens the composer, if folded away, and puts the caret and keyboard in its input field. */
+ fun focusInput() {
+ val binding = _binding ?: return
+ setVisible(true)
+ binding.promptInputEdittext.requestFocus()
+ val imm = binding.root.context
+ .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.showSoftInput(binding.promptInputEdittext, InputMethodManager.SHOW_IMPLICIT)
+ }
+
/** Dismisses the soft keyboard raised by the input field. */
fun hideKeyboard() {
val binding = _binding ?: return
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatMessage.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatMessage.kt
index e476b458..f17d7e45 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatMessage.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatMessage.kt
@@ -56,5 +56,15 @@ data class ChatMessage(
* once the backend answers as ready, so a key saved afterwards leaves no stranded warning
* behind. False for every message stored before this field existed.
*/
- val isSetupError: Boolean = false
+ val isSetupError: Boolean = false,
+ /**
+ * Absolute paths of the context files attached when this user prompt was sent, so Edit can put
+ * the same chips back. Null on every other message, and on a prompt stored before this existed.
+ */
+ val contextFiles: List? = null,
+ /**
+ * The message this one follows, set only on messages kept in ChatSession.otherBranches; null
+ * there means it opens the chat. On the branch on screen, order alone says what follows what.
+ */
+ val parentId: String? = null
)
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSession.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSession.kt
index c9f59ff3..3c039f16 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSession.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatSession.kt
@@ -22,6 +22,12 @@ import java.util.UUID
* @property generatedTitle the title the selected backend wrote after the first reply, or null
* until then (and for sessions stored before this field, for the same Gson reason). Never
* overrides [name]; see ChatViewModel's title generation.
+ * @property otherBranches every message of the versions not on screen, each with its parentId;
+ * [messages] is always the version on screen. Null until an older prompt is first edited, and
+ * for sessions stored before this field, for the same Gson reason.
+ * @property selectedBranches for a message that has several versions following it, the id of the
+ * one last on screen, so switching back returns there; the chat's start is keyed by
+ * ChatBranches.ROOT. Null alongside [otherBranches].
*/
data class ChatSession(
val id: String = UUID.randomUUID().toString(),
@@ -29,7 +35,9 @@ data class ChatSession(
val messages: List = emptyList(),
val projectKey: String? = null,
val name: String? = null,
- val generatedTitle: String? = null
+ val generatedTitle: String? = null,
+ val otherBranches: List? = null,
+ val selectedBranches: Map? = null
) {
/**
* What names this chat in the session list: the [name] the user gave it, otherwise the
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 2f325786..e62cf109 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
@@ -83,6 +83,10 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
const val TOOLTIP_TAG_SYSTEM_LOG = "agent_system_log"
const val TOOLTIP_TAG_USER_MESSAGE_EXPAND = "agent_user_message_expand"
const val TOOLTIP_TAG_MESSAGE_COPY = "agent_message_copy"
+ /** Edit on a user message, and the composer's cancel-edit button. */
+ const val TOOLTIP_TAG_MESSAGE_EDIT = "agent_message_edit"
+ /** The "< 2/3 >" version switcher on a user message whose older prompt was edited. */
+ const val TOOLTIP_TAG_MESSAGE_VERSIONS = "agent_message_versions"
// Tags for the interactive controls on the AI Settings screen (see AiSettingsFragment).
const val TOOLTIP_TAG_SETTINGS_BACK = "ai_settings_back"
@@ -691,7 +695,47 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
detail = """
Copies the whole message, including any lines folded out of
view, so you can paste it into a file or send it again.
- More actions for a message will appear beside this one.
+ On your own messages, Edit sits beside it.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_MESSAGE_EDIT,
+ summary = "Edit this message and run the agent again from there.",
+ detail = """
+ Edit puts the message back in the message box, with the
+ files that were attached to it. Change it and tap Send, or
+ tap × to cancel and leave the chat as it was.
+ On your latest message, sending replaces it: the original
+ and everything the agent did after it are removed.
+ On an earlier message, sending starts a new version of the
+ chat from that point. The original is kept, and
+ ‹ 2/2 › under the message switches between
+ them. The agent only sees the messages above the one you edited.
+ Changes the agent already made to your files are not
+ undone. Check them, or ask the agent to revert them.
+ Not available while the agent is working or waiting for your
+ approval: stop it first.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_MESSAGE_VERSIONS,
+ summary = "Switch between the versions of this message.",
+ detail = """
+ This message was edited, so the chat has more than one version
+ from here on. ‹ and › show the other
+ versions; 2/3 says which one you are looking at.
+ Each version is its own conversation: the agent remembers only
+ the version on screen, never what was said in the others.
+ Files are not switched. Changes the agent made to your
+ project in one version stay when you show another.
+ Not available while the agent is working or waiting for your
+ approval: stop it first.
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatBranches.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatBranches.kt
new file mode 100644
index 00000000..4004c53a
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatBranches.kt
@@ -0,0 +1,189 @@
+package com.itsaky.androidide.plugins.aicore.viewmodel
+
+import com.itsaky.androidide.plugins.aicore.models.ChatMessage
+import com.itsaky.androidide.plugins.aicore.models.ChatSession
+import com.itsaky.androidide.plugins.aicore.models.Sender
+
+/**
+ * The versions a chat grows when an older prompt is edited: the edited prompt starts a new branch
+ * from the messages above it, and the original is kept beside it rather than discarded.
+ *
+ * A session's `messages` is always the branch on screen; every other branch's messages sit in
+ * `otherBranches`, each naming the message it follows. Kept free of Android and of the ViewModel,
+ * so the tree rules are unit-testable on their own.
+ */
+internal object ChatBranches {
+
+ /** The `selectedBranches` key for the chat's start, which has no message of its own. */
+ const val ROOT = ""
+
+ /**
+ * Where a prompt sits among its versions.
+ *
+ * @property index zero-based, oldest version first.
+ * @property count how many versions there are; always at least two.
+ */
+ data class Position(val index: Int, val count: Int)
+
+ /** Oldest first; the id breaks a same-millisecond tie, so no order depends on what is on screen. */
+ private val versionOrder = compareBy({ it.timestamp }, { it.id })
+
+ /**
+ * Every prompt on screen that has other versions, and where it sits among them.
+ *
+ * @param session the current chat.
+ * @return message id to position; empty for a chat that was never forked.
+ */
+ fun positions(session: ChatSession): Map {
+ val others = session.otherBranches.orEmpty()
+ if (others.isEmpty()) return emptyMap()
+ val path = session.messages
+ val result = HashMap()
+ path.forEachIndexed { index, message ->
+ if (message.sender != Sender.USER) return@forEachIndexed
+ val versions = versionsAt(path, others, index)
+ if (versions.size > 1) {
+ result[message.id] = Position(versions.indexOfFirst { it.id == message.id }, versions.size)
+ }
+ }
+ return result
+ }
+
+ /**
+ * Moves [messageId] and everything after it into `otherBranches`, leaving the messages above
+ * it on screen for the edited prompt to follow. Nothing is lost: the original is now a version.
+ *
+ * @param session the current chat.
+ * @param messageId a user prompt on screen.
+ * @return the forked session, or null when [messageId] is not a prompt on screen.
+ */
+ fun fork(session: ChatSession, messageId: String): ChatSession? {
+ val path = session.messages
+ val index = path.indexOfFirst { it.id == messageId }
+ if (index < 0 || path[index].sender != Sender.USER) return null
+ val (others, selected) = archive(session, index)
+ return session.copy(
+ messages = path.subList(0, index).toList(),
+ otherBranches = others,
+ selectedBranches = selected,
+ )
+ }
+
+ /**
+ * Puts version [targetId] of the prompt [messageId] on screen, followed by whatever was last
+ * on screen after it; the branch it replaces is kept for switching back.
+ *
+ * @param session the current chat.
+ * @param messageId a user prompt on screen.
+ * @param targetId another version of that prompt, from `otherBranches`.
+ * @return the switched session, or null when [targetId] is not a version of [messageId].
+ */
+ fun switchTo(session: ChatSession, messageId: String, targetId: String): ChatSession? {
+ val path = session.messages
+ val index = path.indexOfFirst { it.id == messageId }
+ if (index < 0 || path[index].sender != Sender.USER) return null
+ val parent = parentIdAt(path, index)
+ val target = session.otherBranches.orEmpty().firstOrNull {
+ it.id == targetId && it.sender == Sender.USER && it.parentId == parent
+ } ?: return null
+ val (archived, selected) = archive(session, index)
+ val tail = descend(target, archived, selected)
+ val tailIds = tail.mapTo(HashSet()) { it.id }
+ return session.copy(
+ // Order says what follows what on screen, so the stored link is dropped there.
+ messages = path.subList(0, index) + tail.map { it.copy(parentId = null) },
+ otherBranches = archived.filterNot { it.id in tailIds },
+ selectedBranches = selected + ((parent ?: ROOT) to target.id),
+ )
+ }
+
+ /**
+ * The version of [messageId] [step] places away from it, oldest first.
+ *
+ * @return that version's id, or null past either end or when the prompt has one version.
+ */
+ fun versionId(session: ChatSession, messageId: String, step: Int): String? {
+ val path = session.messages
+ val index = path.indexOfFirst { it.id == messageId }
+ if (index < 0) return null
+ val versions = versionsAt(path, session.otherBranches.orEmpty(), index)
+ val target = versions.indexOfFirst { it.id == messageId } + step
+ return versions.getOrNull(target)?.id?.takeUnless { it == messageId }
+ }
+
+ /**
+ * Drops the on-screen messages [doomed] accepts, re-pointing every stored version that followed
+ * one of them at the nearest message kept above it, so no version is left following nothing.
+ *
+ * @param session the current chat.
+ * @param doomed picks the on-screen messages to remove; stored versions are never removed.
+ * @return the session without them; [session] itself when nothing matched.
+ */
+ fun removeFromScreen(session: ChatSession, doomed: (ChatMessage) -> Boolean): ChatSession {
+ val path = session.messages
+ if (path.none(doomed)) return session
+ // What each removed message is replaced by as a parent: the nearest survivor above it.
+ val successor = HashMap()
+ var keptAbove: String? = null
+ for (message in path) {
+ if (doomed(message)) successor[message.id] = keptAbove else keptAbove = message.id
+ }
+ fun repoint(id: String?): String? = if (id != null && id in successor) successor[id] else id
+ return session.copy(
+ messages = path.filterNot(doomed),
+ otherBranches = session.otherBranches?.map { it.copy(parentId = repoint(it.parentId)) },
+ selectedBranches = session.selectedBranches?.mapKeys { (key, _) ->
+ if (key == ROOT) key else repoint(key) ?: ROOT
+ },
+ )
+ }
+
+ /** The prompt at [index] and every stored version of it, oldest first. */
+ private fun versionsAt(
+ path: List,
+ others: List,
+ index: Int,
+ ): List {
+ val parent = parentIdAt(path, index)
+ val siblings = others.filter { it.sender == Sender.USER && it.parentId == parent }
+ return (siblings + path[index]).sortedWith(versionOrder)
+ }
+
+ /** What the message at [index] follows: the one above it, or null at the chat's start. */
+ private fun parentIdAt(path: List, index: Int): String? =
+ if (index == 0) null else path[index - 1].id
+
+ /**
+ * Links the branch from [index] down into `otherBranches`, and records it as the one last on
+ * screen at every step, so a later switch back lands on it and not on an older sibling.
+ */
+ private fun archive(session: ChatSession, index: Int): Pair, Map> {
+ val path = session.messages
+ val selected = session.selectedBranches.orEmpty().toMutableMap()
+ val tail = (index until path.size).map { i ->
+ val parent = parentIdAt(path, i)
+ selected[parent ?: ROOT] = path[i].id
+ path[i].copy(parentId = parent)
+ }
+ return (session.otherBranches.orEmpty() + tail) to selected
+ }
+
+ /**
+ * [start] and the branch below it that was last on screen, falling back on the newest where
+ * nothing was recorded.
+ */
+ private fun descend(
+ start: ChatMessage,
+ others: List,
+ selected: Map,
+ ): List {
+ val children = others.groupBy { it.parentId }
+ val branch = mutableListOf(start)
+ while (true) {
+ val next = children[branch.last().id] ?: break
+ branch += next.firstOrNull { it.id == selected[branch.last().id] }
+ ?: next.maxWith(versionOrder)
+ }
+ return branch
+ }
+}
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 1afb84eb..5818478e 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
@@ -53,7 +53,6 @@ import com.itsaky.androidide.plugins.services.SharedServices
import java.io.File
import java.io.IOException
import java.util.UUID
-import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CancellationException
@@ -71,6 +70,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.future.await
@@ -335,7 +335,7 @@ class ChatViewModel(
private val generationEpoch = AtomicInteger(0)
/** True while a generation is admitted and its coroutine has not yet unwound; gates re-entry. */
- private val isGenerating = AtomicBoolean(false)
+ private val isGenerating = MutableStateFlow(false)
/** Whether the current run's most recent tool batch failed; reset per run. */
@Volatile
@@ -381,9 +381,48 @@ class ChatViewModel(
@Volatile
private var historySizeBeforeLastRun = 0
+ /**
+ * The user message the last run posted, so [editPrompt] knows [historySizeBeforeLastRun]
+ * describes that prompt and not an earlier one; null when the retry point is forgotten.
+ */
+ @Volatile
+ private var lastRunUserMessageId: String? = null
+
/** The tool awaiting approval, straight from [approvalManager] — no polling in between. */
val pendingApprovalRequest: StateFlow = approvalManager.currentApprovalRequest
+ /**
+ * Whether the user's prompts offer Edit and version switching: false while a run is generating,
+ * executing a tool, or waiting on an approval, which counts as busy on its own. A stopped run
+ * stays busy until its coroutine unwinds, which a blocking tool can hold up for minutes.
+ */
+ val canChangePrompts: StateFlow =
+ combine(isGenerating, _agentState, pendingApprovalRequest, ::isIdle)
+ .stateIn(viewModelScope, SharingStarted.Eagerly, false)
+
+ /** Every prompt on screen with other versions, and which of them it is; see [ChatBranches]. */
+ internal val promptVersions: StateFlow