Skip to content

Commit 01dc347

Browse files
committed
feat(ai): polish the Agent chat UX [ADFA-6214]
Name the active chat with a backend-written title, grow the prompt and let it scroll, send with Shift+Enter, show user messages as right-aligned bubbles that fold when long, put a copy action on user and agent messages, and add a direction arrow to the sidebar toggle. Add a divider between the key section and the models in both settings panes.
1 parent 861df74 commit 01dc347

40 files changed

Lines changed: 1609 additions & 163 deletions

‎plugins/AI-Agent-Gemini/src/main/res/layout/fragment_gemini_settings.xml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,14 @@
110110
android:layout_marginTop="@dimen/space_sm"
111111
android:text="@string/btn_get_free_key" />
112112

113+
<!-- Divider: ends the key section before the models. The Model header's top margin below
114+
mirrors this one's, so the line sits centred in the gap. -->
115+
<View
116+
android:layout_width="match_parent"
117+
android:layout_height="1dp"
118+
android:layout_marginTop="@dimen/space_xl"
119+
android:background="@color/plugin_outline_variant" />
120+
113121
<!-- Model. One dropdown, the same control the OpenAI pane uses: the catalog is whatever the
114122
key can reach, so the list is the only way to change it. The chevron is not declared here —
115123
declared as app:endIconDrawable it draws blank inside the host, so

‎plugins/AI-Agent-OpenAI/src/main/res/layout/fragment_openai_settings.xml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,14 @@
194194

195195
</LinearLayout>
196196

197+
<!-- Divider: ends the key section before the models. The Model header's top margin below
198+
mirrors this one's, so the line sits centred in the gap. -->
199+
<View
200+
android:layout_width="match_parent"
201+
android:layout_height="1dp"
202+
android:layout_marginTop="@dimen/space_xl"
203+
android:background="@color/plugin_outline_variant" />
204+
197205
<!-- Model. One editable dropdown: type any name, or pick one the server reported. -->
198206

199207
<TextView

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ dependencies {
7373
implementation("androidx.fragment:fragment-ktx:1.8.8")
7474
implementation("com.google.android.material:material:1.10.0")
7575
implementation("androidx.recyclerview:recyclerview:1.3.2")
76-
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
7776
implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.0")
7877
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
7978

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

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,17 @@ <h2>Your Gemini API key</h2>
203203
app's data, for example — the stored key can no longer be read and will need to
204204
be entered again; tapping <b>Edit</b> tells you when that has happened.</p>
205205

206+
<h2>Writing a message</h2>
207+
<ul>
208+
<li>The prompt field grows as you type, up to about eight lines, and then scrolls: drag inside it to
209+
read back over a long prompt without moving the panel it sits in.</li>
210+
<li>With a hardware keyboard attached, <b>Shift+Enter</b> sends the message
211+
and <b>Enter</b> starts a new line.</li>
212+
<li>A message the agent cannot send — no backend configured, or no key saved
213+
— is left in the field, so the warning never costs you what you typed. The
214+
warning itself goes away on its own once the backend is configured.</li>
215+
</ul>
216+
206217
<h2>Reading the conversation</h2>
207218
<ul>
208219
<li><b>Retry</b> appears on a step that failed — including a tool call you
@@ -216,23 +227,32 @@ <h2>Reading the conversation</h2>
216227
<li><b>System log</b> rows are collapsed records of the agent's internal
217228
steps: tools run, files touched, backend errors. Tap the header to expand
218229
one. They are saved with the session but are not sent to the model.</li>
219-
<li>Long-press a message to copy its text, or to edit and re-send one of
220-
your own.</li>
230+
<li>Your own messages longer than four lines are shown folded. Tap the
231+
down arrow in the bubble's corner to read the whole message, and the up
232+
arrow to fold it again. The model always receives all of it.</li>
233+
<li>Tap the copy icon under a message, yours or the agent's, to copy its
234+
text. It appears under a reply once the agent has finished writing it.</li>
221235
</ul>
222236

223237
<h2>Keeping several chats</h2>
224238
<p>Chats belong to the project they were started in. Opening a project shows
225239
that project's chats and no one else's, and you can keep as many as you want —
226240
one for a build failure, one for a refactor, one for the feature you are adding
227241
— instead of letting a single conversation collect all three.</p>
242+
<p>The header of the Agent tab names the chat you are in, shortened to one line;
243+
long-press it for the same note as every other control on the screen.</p>
244+
<p>After the agent's first reply in a chat, the backend you selected writes a short
245+
title for it from your first message and that reply. Meanwhile the header reads
246+
<i>Naming chat…</i>; if the backend cannot write one, the chat is named after the
247+
first ten words of your first message.</p>
228248
<p>The button at the top right of the Agent tab slides a <b>sidebar</b> in over
229249
the conversation. Its three parts are fixed in place except the middle one, which
230250
is the only part that scrolls:</p>
231251
<ul>
232252
<li><b>New chat</b>, at the top, starts a fresh conversation. The one you were
233253
in is kept, and you can go back to it whenever you like.</li>
234254
<li><b>Recent</b>, in the middle, lists this project's conversations, newest
235-
first, each named after its first message and dated from when it began, with
255+
first, each named with a short title and dated from when it began, with
236256
a tick on the one you are in. It holds the newest to begin with and loads
237257
more as you scroll, so a project with a long history still opens at once.
238258
Tap a row to carry on with it: the agent reloads that conversation, so the
@@ -250,8 +270,8 @@ <h2>Keeping several chats</h2>
250270
left of that row closes the sidebar, as does tapping the dimmed conversation
251271
beside it or pressing <b>Back</b>.</p>
252272
<p>The <b>⋮</b> button on a row in <b>Recent</b> renames or deletes that chat. A
253-
name you give it replaces the first-message title; clearing the name gives the
254-
first-message title back. Deleting cannot be undone — and deleting the last chat
273+
name you give it replaces the automatic title; clearing the name gives the
274+
automatic title back. Deleting cannot be undone — and deleting the last chat
255275
leaves you an empty one to carry on in, rather than nothing.</p>
256276
<p>To clear out several at once, <b>long-press</b> a row. Every row gets a
257277
checkbox, tapping a row ticks it instead of opening it, and the bin at the top of

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

Lines changed: 112 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
package com.itsaky.androidide.plugins.aicore.adapters
22

3-
import android.content.ClipData
4-
import android.content.ClipboardManager
5-
import android.content.Context
63
import android.view.LayoutInflater
74
import android.view.View
85
import android.view.ViewGroup
6+
import android.view.ViewTreeObserver
97
import android.widget.Button
8+
import android.widget.ImageButton
109
import android.widget.ImageView
1110
import android.widget.LinearLayout
12-
import android.widget.PopupMenu
1311
import android.widget.ProgressBar
1412
import android.widget.TextView
1513
import androidx.recyclerview.widget.DiffUtil
1614
import androidx.recyclerview.widget.ListAdapter
1715
import androidx.recyclerview.widget.RecyclerView
18-
import com.google.android.material.snackbar.Snackbar
1916
import com.itsaky.androidide.plugins.aicore.R
2017
import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX
2118
import 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
*/
3839
class 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

Comments
 (0)