Skip to content

Commit 486e8fc

Browse files
authored
ADFA-6276 | Allow editing sent prompts and branching chat versions (#115)
* feat(ai-core): edit sent prompts and branch the chat into versions Edit loads a prompt and its files into the composer, pulsing its bubble; sending replaces the latest prompt or forks an earlier one, chevrons switch versions, and history commits on Main so Stop, clear or a chat switch can't race the run. Refs: ADFA-6276 * fix(ai-core): keep edit mode across tab switches and after Stop Restore an in-progress edit from saved state, gate Edit on isGenerating, relink versions when Stop drops empty bubbles, share showTranscript. Refs: ADFA-6276 * test(ai-core): add slf4j-api to the JVM test classpath plugin-api's PromptConfigStore logs through slf4j, which only the host provides. Refs: ADFA-6276
1 parent 9d3c965 commit 486e8fc

25 files changed

Lines changed: 1713 additions & 74 deletions

‎plugins/AI-Core/build.gradle.kts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ dependencies {
8585
testImplementation(files("../../libs/plugin-api.jar"))
8686
// plugin-api's prompt loader parses YAML with the host's copy; JVM tests need their own, same version
8787
testImplementation("org.snakeyaml:snakeyaml-engine:2.10")
88+
// plugin-api's PromptConfigStore logs through the host's slf4j; JVM tests need their own, same version
89+
testRuntimeOnly("org.slf4j:slf4j-api:2.0.12")
8890
testImplementation("junit:junit:4.13.2")
8991
testImplementation("io.mockk:mockk:1.13.8")
9092
testImplementation("org.json:json:20240303")

‎plugins/AI-Core/src/main/assets/docs/index.html‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,43 @@ <h2>Reading the conversation</h2>
262262
text. It appears under a reply once the agent has finished writing it.</li>
263263
</ul>
264264

265+
<h2>Editing a message you sent</h2>
266+
<p>Tap the pencil under any of your messages to fix a typo, add a detail you
267+
left out, or rephrase a request the agent misread. The message goes back into
268+
the message box with the files that were attached to it, under an
269+
<b>Editing message</b> banner.</p>
270+
<ul>
271+
<li>On your <b>latest</b> message, <b>Send</b> replaces it: the original and
272+
everything after it — the agent's replies and the tools it ran — are removed
273+
from the chat and from what the model remembers, then the agent runs on the
274+
edited message.</li>
275+
<li>On an <b>earlier</b> message, <b>Send</b> starts a new version of the chat
276+
from that point instead, and nothing is removed. The agent is given only the
277+
messages above the one you edited.</li>
278+
<li>The <b>&times;</b> on the banner cancels: the chat is left exactly as it
279+
was, and whatever you had typed before tapping the pencil comes back.</li>
280+
<li><b>Changes the agent already made to your files are not undone.</b> If
281+
an earlier run edited or created files, check them, or ask the agent to
282+
revert them.</li>
283+
<li>Messages cannot be edited while the agent is working or waiting for your
284+
approval. Stop it first.</li>
285+
</ul>
286+
287+
<h2>Versions of a chat</h2>
288+
<p>A message you edited from further up shows <b>&lsaquo; 2/2 &rsaquo;</b>
289+
underneath. Tap the arrows to switch between its versions; the chat below it
290+
changes to what was said in that version.</p>
291+
<ul>
292+
<li>Each version is its own conversation. The agent remembers only the
293+
version on screen, and knows nothing of what was said in the others.</li>
294+
<li>Versions can have versions: editing a message inside one adds arrows
295+
there too, and switching back returns to the one you last looked at.</li>
296+
<li><b>Files are not switched.</b> Your project stays as it is whichever
297+
version is on screen.</li>
298+
<li><b>Clear Chat</b> removes every version. Exporting a chat saves the
299+
version on screen.</li>
300+
</ul>
301+
265302
<h2>Keeping several chats</h2>
266303
<p>Chats belong to the project they were started in. Opening a project shows
267304
that project's chats and no one else's, and you can keep as many as you want —

‎plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/adapters/ChatAdapter.kt‎

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.itsaky.androidide.plugins.aicore.adapters
22

3+
import android.animation.ValueAnimator
4+
import android.graphics.drawable.GradientDrawable
35
import android.view.LayoutInflater
46
import android.view.View
57
import android.view.ViewGroup
@@ -19,6 +21,7 @@ import com.itsaky.androidide.plugins.aicore.models.ChatMessage
1921
import com.itsaky.androidide.plugins.aicore.models.MessageStatus
2022
import com.itsaky.androidide.plugins.aicore.models.Sender
2123
import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
24+
import com.itsaky.androidide.plugins.aicore.viewmodel.ChatBranches
2225
import io.noties.markwon.Markwon
2326
import java.text.DecimalFormat
2427
import java.text.SimpleDateFormat
@@ -48,6 +51,45 @@ class ChatAdapter(
4851
private val decimalSecondsFormatter = DecimalFormat("0.0")
4952
private val expandedMessageIds = mutableSetOf<String>()
5053
private val animatingHolders = mutableSetOf<DefaultMessageViewHolder>()
54+
private val pulsingHolders = mutableSetOf<DefaultMessageViewHolder>()
55+
56+
/**
57+
* Whether user messages offer Edit and version switching, from ChatViewModel.canChangePrompts;
58+
* false while a run is in flight.
59+
*/
60+
var canChangePrompts: Boolean = false
61+
set(value) {
62+
if (field == value) return
63+
field = value
64+
notifyUserRowsChanged { true }
65+
}
66+
67+
/** Every user message with other versions, and which of them it is; see ChatBranches. */
68+
internal var promptVersions: Map<String, ChatBranches.Position> = emptyMap()
69+
set(value) {
70+
val previous = field
71+
if (previous == value) return
72+
field = value
73+
notifyUserRowsChanged { previous[it] != value[it] }
74+
}
75+
76+
/** The user message loaded into the composer for editing, or null; ChatFragment owns the state. */
77+
var editingMessageId: String? = null
78+
set(value) {
79+
val previous = field
80+
if (previous == value) return
81+
field = value
82+
notifyUserRowsChanged { it == previous || it == value }
83+
}
84+
85+
/** Rebinds the actions of the user rows [changed] picks; a payload keeps their text as it is. */
86+
private inline fun notifyUserRowsChanged(changed: (messageId: String) -> Boolean) {
87+
currentList.forEachIndexed { position, message ->
88+
if (message.sender == Sender.USER && changed(message.id)) {
89+
notifyItemChanged(position, PromptActionsPayload)
90+
}
91+
}
92+
}
5193

5294
companion object {
5395
private const val VIEW_TYPE_DEFAULT = 0
@@ -56,7 +98,13 @@ class ChatAdapter(
5698

5799
const val ACTION_RETRY = "retry"
58100
const val ACTION_COPY = "copy"
101+
const val ACTION_EDIT = "edit"
102+
const val ACTION_PREVIOUS_VERSION = "previous_version"
103+
const val ACTION_NEXT_VERSION = "next_version"
59104
const val ACTION_OPEN_SETTINGS = "open_settings"
105+
106+
/** A version arrow with nowhere to go, or held back while the agent works. */
107+
private const val DISABLED_ALPHA = 0.38f
60108
}
61109

62110
sealed class MessageViewHolder(view: View) : RecyclerView.ViewHolder(view)
@@ -72,15 +120,25 @@ class ChatAdapter(
72120
val btnRetry: Button = view.findViewById(R.id.btn_retry)
73121
/** Fold toggle; only the user bubble layout has one. */
74122
val btnToggleExpand: ImageButton? = view.findViewById(R.id.btn_toggle_expand)
123+
/** The bordered bubble; only the user bubble layout has one. */
124+
val userBubble: View? = view.findViewById(R.id.user_bubble)
75125
val messageActions: LinearLayout = view.findViewById(R.id.message_actions)
76126
val btnCopyMessage: ImageButton = view.findViewById(R.id.btn_copy_message)
127+
val btnEditMessage: ImageButton = view.findViewById(R.id.btn_edit_message)
128+
val versionNav: LinearLayout = view.findViewById(R.id.version_nav)
129+
val btnPreviousVersion: ImageButton = view.findViewById(R.id.btn_previous_version)
130+
val versionLabel: TextView = view.findViewById(R.id.version_label)
131+
val btnNextVersion: ImageButton = view.findViewById(R.id.btn_next_version)
77132

78133
/**
79134
* Queued next step of the "..." animation, or null when it isn't running. Retained so
80135
* [ChatAdapter.hideGeneratingDots] can cancel it: a Runnable left on the main looper
81136
* would keep this holder, its views and their Context reachable after the row is gone.
82137
*/
83138
var generatingDotsStep: Runnable? = null
139+
140+
/** Pulses [userBubble]'s border while its prompt is being edited; null otherwise. */
141+
var editPulse: ValueAnimator? = null
84142
}
85143

86144
class SystemMessageViewHolder(view: View) : MessageViewHolder(view) {
@@ -151,6 +209,8 @@ class ChatAdapter(
151209
if (payloads.isEmpty()) {
152210
// No payload, do full bind
153211
onBindViewHolder(holder, position)
212+
} else if (payloads.all { it === PromptActionsPayload }) {
213+
if (holder is DefaultMessageViewHolder) updateMessageActions(holder, getItem(position))
154214
} else {
155215
// Handle payload update
156216
val payload = payloads[0]
@@ -265,11 +325,23 @@ class ChatAdapter(
265325

266326
/** Wired once per holder: a streamed reply grows via payloads, so the tap reads the current item. */
267327
private fun wireMessageActions(holder: DefaultMessageViewHolder) {
268-
holder.btnCopyMessage.setOnClickListener {
328+
holder.btnCopyMessage.sendsAction(holder, ACTION_COPY)
329+
wireTooltip(holder.btnCopyMessage, AiCorePlugin.TOOLTIP_TAG_MESSAGE_COPY)
330+
holder.btnEditMessage.sendsAction(holder, ACTION_EDIT)
331+
wireTooltip(holder.btnEditMessage, AiCorePlugin.TOOLTIP_TAG_MESSAGE_EDIT)
332+
holder.btnPreviousVersion.sendsAction(holder, ACTION_PREVIOUS_VERSION)
333+
holder.btnNextVersion.sendsAction(holder, ACTION_NEXT_VERSION)
334+
wireTooltip(holder.btnPreviousVersion, AiCorePlugin.TOOLTIP_TAG_MESSAGE_VERSIONS)
335+
wireTooltip(holder.btnNextVersion, AiCorePlugin.TOOLTIP_TAG_MESSAGE_VERSIONS)
336+
wireTooltip(holder.versionLabel, AiCorePlugin.TOOLTIP_TAG_MESSAGE_VERSIONS)
337+
}
338+
339+
/** Taps report [action] on the item [holder] shows now, which a streamed reply may have grown. */
340+
private fun View.sendsAction(holder: RecyclerView.ViewHolder, action: String) {
341+
setOnClickListener {
269342
val pos = holder.bindingAdapterPosition
270-
if (pos != RecyclerView.NO_POSITION) onMessageAction(ACTION_COPY, getItem(pos))
343+
if (pos != RecyclerView.NO_POSITION) onMessageAction(action, getItem(pos))
271344
}
272-
wireTooltip(holder.btnCopyMessage, AiCorePlugin.TOOLTIP_TAG_MESSAGE_COPY)
273345
}
274346

275347
/**
@@ -283,6 +355,61 @@ class ChatAdapter(
283355
val copyable = message.sender == Sender.USER || message.sender == Sender.AGENT
284356
val show = copyable && message.status != MessageStatus.LOADING && !streaming
285357
holder.messageActions.visibility = if (show) View.VISIBLE else View.GONE
358+
val isUser = message.sender == Sender.USER
359+
holder.btnEditMessage.visibility = if (isUser && canChangePrompts) View.VISIBLE else View.GONE
360+
// Disabled outright: the prompt is already in the composer, and Cancel carries the tooltip.
361+
val editing = isUser && message.id == editingMessageId
362+
holder.btnEditMessage.isEnabled = !editing
363+
holder.btnEditMessage.alpha = if (editing) DISABLED_ALPHA else 1f
364+
if (editing) startEditPulse(holder) else stopEditPulse(holder)
365+
val version = promptVersions[message.id]?.takeIf { isUser }
366+
holder.versionNav.visibility = if (version != null) View.VISIBLE else View.GONE
367+
if (version != null) {
368+
val context = holder.versionLabel.context
369+
holder.versionLabel.text =
370+
context.getString(R.string.message_version_label, version.index + 1, version.count)
371+
holder.versionLabel.contentDescription =
372+
context.getString(R.string.desc_message_version, version.index + 1, version.count)
373+
// Dimmed rather than disabled: a disabled view drops long-press, and with it the tooltip.
374+
val canGoOlder = canChangePrompts && version.index > 0
375+
val canGoNewer = canChangePrompts && version.index < version.count - 1
376+
holder.btnPreviousVersion.alpha = if (canGoOlder) 1f else DISABLED_ALPHA
377+
holder.btnNextVersion.alpha = if (canGoNewer) 1f else DISABLED_ALPHA
378+
}
379+
}
380+
381+
/**
382+
* Fades the bubble's border between its usual grey and the editing blue, and back, until
383+
* [stopEditPulse]. Leaves a pulse already running alone, so a rebind doesn't restart it.
384+
*/
385+
private fun startEditPulse(holder: DefaultMessageViewHolder) {
386+
val bubble = holder.userBubble ?: return
387+
if (holder.editPulse != null) return
388+
bubble.setBackgroundResource(R.drawable.bg_user_message_editing)
389+
// Mutated, so the pulse recolors this bubble only and not every user of the drawable.
390+
val background = bubble.background.mutate() as GradientDrawable
391+
val context = bubble.context
392+
val strokeWidth = context.resources.getDimensionPixelSize(R.dimen.chat_user_bubble_editing_stroke_width)
393+
holder.editPulse = ValueAnimator.ofArgb(
394+
context.getColor(R.color.plugin_outline_variant),
395+
context.getColor(R.color.plugin_editing),
396+
).apply {
397+
duration = context.resources.getInteger(R.integer.chat_user_bubble_editing_pulse_ms).toLong()
398+
repeatMode = ValueAnimator.REVERSE
399+
repeatCount = ValueAnimator.INFINITE
400+
addUpdateListener { background.setStroke(strokeWidth, it.animatedValue as Int) }
401+
start()
402+
}
403+
pulsingHolders.add(holder)
404+
}
405+
406+
/** Cancels the pulse, if any, and gives the bubble its usual border back. */
407+
private fun stopEditPulse(holder: DefaultMessageViewHolder) {
408+
val pulse = holder.editPulse ?: return
409+
pulse.cancel()
410+
holder.editPulse = null
411+
pulsingHolders.remove(holder)
412+
holder.userBubble?.setBackgroundResource(R.drawable.bg_user_message)
286413
}
287414

288415
private fun bindExpandToggle(holder: DefaultMessageViewHolder, toggle: ImageButton, message: ChatMessage) {
@@ -417,18 +544,31 @@ class ChatAdapter(
417544
}
418545

419546
/**
420-
* Stop every live "…" animation. Call from the host fragment's `onDestroyView`:
547+
* Stop every live "…" animation and edit pulse. Call from the host fragment's `onDestroyView`:
421548
* a message still streaming when the tab closes never reaches a terminal status
422549
* and its holder is never recycled, so nothing else cancels its Runnable.
423550
*/
424551
fun stopAllAnimations() {
425552
animatingHolders.toList().forEach { hideGeneratingDots(it) }
553+
pulsingHolders.toList().forEach { stopEditPulse(it) }
554+
}
555+
556+
// A row scrolled off but still cached keeps its pulse; it only ticks while on screen.
557+
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
558+
super.onViewDetachedFromWindow(holder)
559+
(holder as? DefaultMessageViewHolder)?.editPulse?.pause()
560+
}
561+
562+
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
563+
super.onViewAttachedToWindow(holder)
564+
(holder as? DefaultMessageViewHolder)?.editPulse?.resume()
426565
}
427566

428567
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
429568
super.onViewRecycled(holder)
430569
if (holder is DefaultMessageViewHolder) {
431570
hideGeneratingDots(holder)
571+
stopEditPulse(holder)
432572
// Here, not in bind: a rebind of the same row would flash its toggle off for a frame.
433573
holder.btnToggleExpand?.visibility = View.GONE
434574
}
@@ -520,4 +660,7 @@ class ChatAdapter(
520660

521661
// Payload for partial updates
522662
data class TextUpdatePayload(val text: String, val status: MessageStatus)
663+
664+
/** Payload for a user row whose Edit or version controls changed; nothing else about it did. */
665+
object PromptActionsPayload
523666
}

0 commit comments

Comments
 (0)