11package com.itsaky.androidide.plugins.aicore.adapters
22
3- import android.content.ClipData
4- import android.content.ClipboardManager
5- import android.content.Context
63import android.view.LayoutInflater
74import android.view.View
85import android.view.ViewGroup
6+ import android.view.ViewTreeObserver
97import android.widget.Button
8+ import android.widget.ImageButton
109import android.widget.ImageView
1110import android.widget.LinearLayout
12- import android.widget.PopupMenu
1311import android.widget.ProgressBar
1412import android.widget.TextView
1513import androidx.recyclerview.widget.DiffUtil
1614import androidx.recyclerview.widget.ListAdapter
1715import androidx.recyclerview.widget.RecyclerView
18- import com.google.android.material.snackbar.Snackbar
1916import com.itsaky.androidide.plugins.aicore.R
2017import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX
2118import com.itsaky.androidide.plugins.aicore.models.ChatMessage
@@ -34,10 +31,16 @@ private const val TAG = "$LOG_PREFIX.ChatAdapter"
3431 * @param wireTooltip attaches this plugin's long-press help for a tag to a view. Supplied by
3532 * ChatFragment, which owns the [com.itsaky.androidide.plugins.services.IdeTooltipService]
3633 * lookup, so the adapter stays free of service plumbing. Defaults to a no-op for tests.
34+ * @param isUserMessageExpanded whether a user bubble is unfolded; the state lives in ChatViewModel,
35+ * so a fold outlives this adapter. Defaults to always folded for tests.
36+ * @param toggleUserMessageExpanded unfolds or folds a user bubble, returning its new state.
37+ * @param onMessageAction runs one of the `ACTION_*` constants for a message, in ChatFragment.
3738 */
3839class ChatAdapter (
3940 private val markwon : Markwon ,
4041 private val wireTooltip : (View , String ) -> Unit = { _, _ -> },
42+ private val isUserMessageExpanded : (messageId: String ) -> Boolean = { false },
43+ private val toggleUserMessageExpanded : (messageId: String ) -> Boolean = { false },
4144 private val onMessageAction : (action: String , message: ChatMessage ) -> Unit
4245) : ListAdapter<ChatMessage, RecyclerView.ViewHolder>(DiffCallback ) {
4346
@@ -49,9 +52,11 @@ class ChatAdapter(
4952 companion object {
5053 private const val VIEW_TYPE_DEFAULT = 0
5154 private const val VIEW_TYPE_SYSTEM = 1
55+ private const val VIEW_TYPE_USER = 2
56+ private const val USER_MESSAGE_COLLAPSED_LINES = 4
5257
53- const val ACTION_EDIT = " edit"
5458 const val ACTION_RETRY = " retry"
59+ const val ACTION_COPY = " copy"
5560 const val ACTION_OPEN_SETTINGS = " open_settings"
5661 }
5762
@@ -66,6 +71,10 @@ class ChatAdapter(
6671 val generatingDots: TextView = view.findViewById(R .id.generating_dots)
6772 val messageDuration: TextView = view.findViewById(R .id.message_duration)
6873 val btnRetry: Button = view.findViewById(R .id.btn_retry)
74+ /* * Fold toggle; only the user bubble layout has one. */
75+ val btnToggleExpand: ImageButton ? = view.findViewById(R .id.btn_toggle_expand)
76+ val messageActions: LinearLayout = view.findViewById(R .id.message_actions)
77+ val btnCopyMessage: ImageButton = view.findViewById(R .id.btn_copy_message)
6978
7079 /* *
7180 * Queued next step of the "..." animation, or null when it isn't running. Retained so
@@ -94,6 +103,8 @@ class ChatAdapter(
94103 VIEW_TYPE_DEFAULT
95104 } else if (message.sender == Sender .SYSTEM ) {
96105 VIEW_TYPE_SYSTEM
106+ } else if (message.sender == Sender .USER ) {
107+ VIEW_TYPE_USER
97108 } else {
98109 VIEW_TYPE_DEFAULT
99110 }
@@ -108,9 +119,23 @@ class ChatAdapter(
108119 val view = inflater.inflate(R .layout.list_item_chat_system_message, parent, false )
109120 SystemMessageViewHolder (view)
110121 }
122+ // Own view type, so a recycled row never carries the bubble over to an agent message.
123+ VIEW_TYPE_USER -> {
124+ val view = inflater.inflate(R .layout.list_item_chat_user_message, parent, false )
125+ DefaultMessageViewHolder (view).also (::wireMessageActions).apply {
126+ // The bubble is the sender cue, so the label would only repeat it.
127+ messageSender.visibility = View .GONE
128+ // A match_parent child can't widen a wrap_content bubble; it stays one word wide.
129+ messageContent.layoutParams.width = ViewGroup .LayoutParams .WRAP_CONTENT
130+ // Line count is only known once the text is laid out at its final width.
131+ messageContent.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
132+ updateExpandToggleVisibility(this )
133+ }
134+ }
135+ }
111136 else -> {
112137 val view = inflater.inflate(R .layout.list_item_chat_message, parent, false )
113- DefaultMessageViewHolder (view)
138+ DefaultMessageViewHolder (view). also (::wireMessageActions)
114139 }
115140 }
116141 }
@@ -164,6 +189,7 @@ class ChatAdapter(
164189 holder.messageContent.text = payload.text
165190 }
166191 }
192+ updateMessageActions(holder, message)
167193 } else if (payload is TextUpdatePayload && holder is SystemMessageViewHolder ) {
168194 markwon.setMarkdown(holder.messageContent, payload.text)
169195 updateSystemMessageExpansion(holder, getItem(position))
@@ -178,12 +204,8 @@ class ChatAdapter(
178204 holder.messageSender.text = message.sender.name.lowercase(Locale .getDefault())
179205 .replaceFirstChar { it.titlecase(Locale .getDefault()) }
180206
181- holder.itemView.setOnLongClickListener { view ->
182- if (message.status == MessageStatus .SENT ) {
183- showContextMenu(view, message)
184- }
185- true
186- }
207+ updateMessageActions(holder, message)
208+ holder.btnToggleExpand?.let { bindExpandToggle(holder, it, message) }
187209
188210 when (message.status) {
189211 MessageStatus .LOADING -> {
@@ -242,6 +264,83 @@ class ChatAdapter(
242264 }
243265 }
244266
267+ /* * Wired once per holder: a streamed reply grows via payloads, so the tap reads the current item. */
268+ private fun wireMessageActions (holder : DefaultMessageViewHolder ) {
269+ holder.btnCopyMessage.setOnClickListener {
270+ val pos = holder.bindingAdapterPosition
271+ if (pos != RecyclerView .NO_POSITION ) onMessageAction(ACTION_COPY , getItem(pos))
272+ }
273+ wireTooltip(holder.btnCopyMessage, AiCorePlugin .TOOLTIP_TAG_MESSAGE_COPY )
274+ }
275+
276+ /* *
277+ * Shows the actions under user and agent messages once their text is final: not while loading,
278+ * nor while a reply is still streaming. System error rows share this layout but get none.
279+ */
280+ private fun updateMessageActions (holder : DefaultMessageViewHolder , message : ChatMessage ) {
281+ val streaming = message.sender == Sender .AGENT &&
282+ message.status == MessageStatus .SENT && message.durationMs == null
283+ val show = message.sender != Sender .SYSTEM && message.status != MessageStatus .LOADING && ! streaming
284+ holder.messageActions.visibility = if (show) View .VISIBLE else View .GONE
285+ }
286+
287+ private fun bindExpandToggle (holder : DefaultMessageViewHolder , toggle : ImageButton , message : ChatMessage ) {
288+ applyUserMessageExpansion(holder, toggle, isUserMessageExpanded(message.id))
289+ toggle.setOnClickListener {
290+ val expanded = toggleUserMessageExpanded(message.id)
291+ val rowTop = holder.itemView.top
292+ applyUserMessageExpansion(holder, toggle, expanded)
293+ keepRowTopInPlace(holder.itemView, rowTop)
294+ }
295+ wireTooltip(toggle, AiCorePlugin .TOOLTIP_TAG_USER_MESSAGE_EXPAND )
296+ }
297+
298+ /* *
299+ * Folds the bubble to [USER_MESSAGE_COLLAPSED_LINES] or unfolds it, and turns the arrow and its
300+ * spoken label to match: down/"show the whole message" while folded, up/"show less" once open.
301+ */
302+ private fun applyUserMessageExpansion (holder : DefaultMessageViewHolder , toggle : ImageButton , expanded : Boolean ) {
303+ holder.messageContent.maxLines = if (expanded) Int .MAX_VALUE else USER_MESSAGE_COLLAPSED_LINES
304+ toggle.setImageResource(if (expanded) R .drawable.ic_expand_less else R .drawable.ic_expand_more)
305+ toggle.contentDescription = toggle.context.getString(
306+ if (expanded) R .string.desc_collapse_user_message else R .string.desc_expand_user_message
307+ )
308+ }
309+
310+ /* *
311+ * Scrolls [row] back to [rowTop] after its next layout, before that frame draws. The list stacks
312+ * from the end, so a row that changes height moves its top: unfolding would push the start of
313+ * the message off screen.
314+ */
315+ private fun keepRowTopInPlace (row : View , rowTop : Int ) {
316+ val list = row.parent as ? RecyclerView ? : return
317+ list.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver .OnPreDrawListener {
318+ override fun onPreDraw (): Boolean {
319+ list.viewTreeObserver.removeOnPreDrawListener(this )
320+ if (row.parent != = list) return true
321+ val drift = row.top - rowTop
322+ if (drift == 0 ) return true
323+ list.scrollBy(0 , drift)
324+ // Skip this frame: it was laid out before the correction.
325+ return false
326+ }
327+ })
328+ }
329+
330+ /* *
331+ * Shows the toggle only when the text runs past the fold. lineCount counts every line even while
332+ * maxLines hides some (no ellipsize is set), so this holds folded or not. Posted, since it runs
333+ * mid-layout and a visibility change there would be deferred with a warning anyway.
334+ */
335+ private fun updateExpandToggleVisibility (holder : DefaultMessageViewHolder ) {
336+ val toggle = holder.btnToggleExpand ? : return
337+ toggle.post {
338+ val overflows = holder.messageContent.lineCount > USER_MESSAGE_COLLAPSED_LINES
339+ val visibility = if (overflows) View .VISIBLE else View .GONE
340+ if (toggle.visibility != visibility) toggle.visibility = visibility
341+ }
342+ }
343+
245344 private fun bindSystemMessage (holder : SystemMessageViewHolder , message : ChatMessage ) {
246345 markwon.setMarkdown(holder.messageContent, message.text)
247346 updateSystemMessageExpansion(holder, message)
@@ -387,34 +486,6 @@ class ChatAdapter(
387486 }
388487 }
389488
390- private fun showContextMenu (view : View , message : ChatMessage ) {
391- val context = view.context
392- val popup = PopupMenu (context, view)
393-
394- popup.menu.add(0 , 1 , 0 , " Copy Text" )
395- if (message.sender == Sender .USER ) {
396- popup.menu.add(0 , 2 , 0 , " Edit Message" )
397- }
398-
399- popup.setOnMenuItemClickListener { item ->
400- when (item.itemId) {
401- 1 -> {
402- val clipboard = context.getSystemService(Context .CLIPBOARD_SERVICE ) as ClipboardManager
403- val clip = ClipData .newPlainText(" chat_message" , message.text)
404- clipboard.setPrimaryClip(clip)
405- Snackbar .make(view, view.context.getString(R .string.msg_copied), Snackbar .LENGTH_SHORT ).show()
406- true
407- }
408- 2 -> {
409- onMessageAction(ACTION_EDIT , message)
410- true
411- }
412- else -> false
413- }
414- }
415- popup.show()
416- }
417-
418489 override fun onCurrentListChanged (previousList : MutableList <ChatMessage >, currentList : MutableList <ChatMessage >) {
419490 super .onCurrentListChanged(previousList, currentList)
420491 expandedMessageIds.clear()
0 commit comments