11package com.itsaky.androidide.plugins.aicore.adapters
22
3+ import android.animation.ValueAnimator
4+ import android.graphics.drawable.GradientDrawable
35import android.view.LayoutInflater
46import android.view.View
57import android.view.ViewGroup
@@ -19,6 +21,7 @@ import com.itsaky.androidide.plugins.aicore.models.ChatMessage
1921import com.itsaky.androidide.plugins.aicore.models.MessageStatus
2022import com.itsaky.androidide.plugins.aicore.models.Sender
2123import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
24+ import com.itsaky.androidide.plugins.aicore.viewmodel.ChatBranches
2225import io.noties.markwon.Markwon
2326import java.text.DecimalFormat
2427import 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