Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
91 commits
Select commit Hold shift + click to select a range
80d4ed3
Adding basic UI
adalpari Oct 17, 2025
4836d41
Renaming
adalpari Oct 17, 2025
37541d0
Some styling
adalpari Oct 17, 2025
51b01a7
Renaming and dummy data
adalpari Oct 17, 2025
6c83ae3
Using proper "new conversation icon"
adalpari Oct 17, 2025
8d7ea50
Conversation details screen
adalpari Oct 17, 2025
a0a146b
Creating the reply bottomsheet
adalpari Oct 17, 2025
eebc0ab
Linking to the support screen
adalpari Oct 17, 2025
37676c8
bottomsheet fix
adalpari Oct 17, 2025
703f4c4
Mov navigation form activity to viewmodel
adalpari Oct 20, 2025
f6be7fd
Adding create ticket screen
adalpari Oct 20, 2025
d345864
More screen adjustments
adalpari Oct 20, 2025
05773ec
Extracting common code
adalpari Oct 20, 2025
b442787
Margin fix
adalpari Oct 20, 2025
cf4762e
detekt
adalpari Oct 20, 2025
7d318b1
Style
adalpari Oct 20, 2025
1ab8f3b
Merge branch 'trunk' into feat/CMM-843-Create-the-Ask-the-HE-entry-UI
adalpari Oct 20, 2025
d585a4a
New ticket check
adalpari Oct 20, 2025
8c651fc
Creating tests
adalpari Oct 20, 2025
fdf926b
Merge branch 'feat/CMM-843-Create-the-Ask-the-HE-entry-UI' of https:/…
adalpari Oct 20, 2025
3515fd0
Creating repository and load conversations function
adalpari Oct 21, 2025
3d99919
Adding createConversation function
adalpari Oct 21, 2025
0be28b4
Creating loadConversation func
adalpari Oct 21, 2025
40a5880
Loading conversations form the viewmodel
adalpari Oct 21, 2025
a55994e
Adding loading spinner
adalpari Oct 21, 2025
c82458c
Pull to refresh
adalpari Oct 21, 2025
73a434a
Proper ionitialization
adalpari Oct 21, 2025
087d07a
Adding empty screen
adalpari Oct 21, 2025
3b5a1e5
Handling send new conversation
adalpari Oct 21, 2025
4febc3d
Show loading when sending
adalpari Oct 21, 2025
e1215a9
New ticket creation fix
adalpari Oct 21, 2025
98cbb1f
Using snackbar for errors
adalpari Oct 21, 2025
5d421d3
Error handling
adalpari Oct 21, 2025
af8e1dd
Answering conversation
adalpari Oct 21, 2025
cad1eec
Adding some test to the repository
adalpari Oct 21, 2025
481ae11
More tests!
adalpari Oct 21, 2025
4e0b242
Merge branch 'trunk' into feat/CMM-872-support-HE-conversations-and-t…
adalpari Oct 22, 2025
95c80bd
Compile fixes
adalpari Oct 22, 2025
d36882e
Similarities improvements
adalpari Oct 22, 2025
563f58b
Using snackbar in bots activity
adalpari Oct 22, 2025
ab8ca28
Extracting EmptyConversationsView
adalpari Oct 22, 2025
034288e
Renaming
adalpari Oct 22, 2025
049df3e
Extracting VM and UI common code
adalpari Oct 22, 2025
15ab84e
Extracting navigation common code
adalpari Oct 22, 2025
53085d0
Renaming VMs for clarification
adalpari Oct 22, 2025
8c057bf
More refactor
adalpari Oct 22, 2025
a4ed792
Capitalise text fields
adalpari Oct 22, 2025
ccef4b7
Updating rs library
adalpari Oct 22, 2025
be6a5f2
Loading conversation UX
adalpari Oct 22, 2025
f023bf8
Style fix
adalpari Oct 22, 2025
d33e512
Fixing scaffolds paddings
adalpari Oct 22, 2025
ca5af7a
userID fix
adalpari Oct 22, 2025
972641d
Fixing the padding problem in bot chat when the keyboard is opened
adalpari Oct 22, 2025
d328103
Apply padding to create ticket screen when the keyboard is opened
adalpari Oct 22, 2025
4501d9a
Fixing scroll state in reply bottomsheet
adalpari Oct 22, 2025
c2baa18
Adding tests for the new common viewmodel
adalpari Oct 22, 2025
6aa8de1
Fixing AIBotSupportViewModel tests
adalpari Oct 22, 2025
204afef
detekt
adalpari Oct 22, 2025
a304d67
Improvements int he conversation interaction
adalpari Oct 22, 2025
6ee853b
Adding tests for HE VM
adalpari Oct 22, 2025
d0a549c
Merge branch 'trunk' into feat/CMM-872-support-HE-conversations-and-t…
adalpari Oct 22, 2025
bc476a7
Merge remote-tracking branch 'origin/trunk' into feat/CMM-872-support…
adalpari Oct 23, 2025
a13ae5b
Saving draft state
adalpari Oct 23, 2025
49f1af3
Properly navigating when a ticket is selected
adalpari Oct 23, 2025
e394c7a
Error parsing improvement
adalpari Oct 23, 2025
a6c421c
accessToken suggestion improvements
adalpari Oct 23, 2025
dbcb453
General suggestions
adalpari Oct 23, 2025
03f00fe
Send message error UX improvement
adalpari Oct 23, 2025
c117fcf
Fixing tests
adalpari Oct 23, 2025
d40d1d2
Converting the UI to more AndroidMaterial style
adalpari Oct 23, 2025
d318c2d
Bots screen renaming
adalpari Oct 23, 2025
c12f0fd
Bots screens renaming
adalpari Oct 23, 2025
1466115
Merge branch 'feat/CMM-872-support-HE-conversations-and-tickets-logic…
adalpari Oct 23, 2025
1d4a490
Make NewTicket screen more Android Material theme as well
adalpari Oct 24, 2025
7232fb2
Adding preview for EmptyConversationsView
adalpari Oct 24, 2025
a6e3e65
Button fix
adalpari Oct 24, 2025
19fcdf6
detekt
adalpari Oct 24, 2025
c41d801
Merge branch 'trunk' into feat/CMM-884-support-Iterate-over-the-whole…
adalpari Oct 27, 2025
6ddffcf
Ticket selection change
adalpari Oct 27, 2025
30fb83f
Supporting markdown text
adalpari Oct 27, 2025
3f908f2
detekt
adalpari Oct 27, 2025
1f6f555
Improving MarkdownUtils
adalpari Oct 27, 2025
727644c
Formatting text in the repository layer instead the ui
adalpari Oct 27, 2025
3eb939c
Renaming
adalpari Oct 27, 2025
455c100
Fixing tests
adalpari Oct 27, 2025
4744542
Parsing markdown more exhaustively
adalpari Oct 28, 2025
ce4641f
New links support
adalpari Oct 28, 2025
55d37a5
Detekt
adalpari Oct 28, 2025
fd316a5
Merge branch 'trunk' into feat/CMM-884-support-Iterate-over-the-whole…
adalpari Oct 30, 2025
d0122ff
CMM-883 support Odie bot conversation pagination (#22316)
adalpari Oct 30, 2025
30f80e4
Merge branch 'trunk' into feat/CMM-884-support-Iterate-over-the-whole…
adalpari Oct 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions WordPress/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ static def addBuildConfigFieldsFromPrefixedProperties(variant, properties, prefi

dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.commonmark)
compileOnly project(path: ':libs:annotations')
ksp project(':libs:processors')
implementation (project(path:':libs:networking')) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package org.wordpress.android.support.aibot.model

import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import java.util.Date

@Immutable
data class BotMessage(
val id: Long,
val text: String,
val rawText: String,
val formattedText: AnnotatedString,
val date: Date,
val isWrittenByUser: Boolean
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ import org.wordpress.android.modules.IO_THREAD
import org.wordpress.android.networking.restapi.WpComApiClientProvider
import org.wordpress.android.support.aibot.model.BotConversation
import org.wordpress.android.support.aibot.model.BotMessage
import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString
import org.wordpress.android.util.AppLog
import rs.wordpress.api.kotlin.WpComApiClient
import rs.wordpress.api.kotlin.WpRequestResult
import uniffi.wp_api.AddMessageToBotConversationParams
import uniffi.wp_api.BotConversationSummary
import uniffi.wp_api.CreateBotConversationParams
import uniffi.wp_api.GetBotConversationParams
import java.util.Date
import javax.inject.Inject
import javax.inject.Named

private const val BOT_ID = "jetpack-chat-mobile"
private const val ITEMS_PER_PAGE = 20

class AIBotSupportRepository @Inject constructor(
private val appLogWrapper: AppLogWrapper,
Expand Down Expand Up @@ -66,12 +69,15 @@ class AIBotSupportRepository @Inject constructor(
}
}

suspend fun loadConversation(chatId: Long): BotConversation? = withContext(ioDispatcher) {
suspend fun loadConversation(chatId: Long, pageNumber: Long = 1L): BotConversation? = withContext(ioDispatcher) {
val response = wpComApiClient.request { requestBuilder ->
requestBuilder.supportBots().getBotConversation(
botId = BOT_ID,
chatId = chatId.toULong(),
params = GetBotConversationParams()
params = GetBotConversationParams(
pageNumber = pageNumber.toULong(),
itemsPerPage = ITEMS_PER_PAGE.toULong()
)
)
}
when (response) {
Expand Down Expand Up @@ -157,15 +163,16 @@ class AIBotSupportRepository @Inject constructor(
BotConversation (
id = chatId.toLong(),
createdAt = createdAt,
mostRecentMessageDate = messages.last().createdAt,
lastMessage = messages.last().content,
mostRecentMessageDate = messages.lastOrNull()?.createdAt ?: Date(),
lastMessage = messages.lastOrNull()?.content.orEmpty(),
messages = messages.map { it.toBotMessage() }
)

private fun uniffi.wp_api.BotMessage.toBotMessage(): BotMessage =
BotMessage(
id = messageId.toLong(),
text = content,
rawText = content,
formattedText = markdownToAnnotatedString(content),
date = createdAt,
isWrittenByUser = role == "user"
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.wordpress.android.support.aibot.ui

import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
Expand All @@ -16,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
Expand All @@ -28,63 +30,80 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import kotlinx.coroutines.launch
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.KeyboardOptions
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.text.style.TextAlign
import org.wordpress.android.R
import org.wordpress.android.support.aibot.util.formatRelativeTime
import org.wordpress.android.support.aibot.util.generateSampleBotConversations
import org.wordpress.android.support.aibot.model.BotConversation
import org.wordpress.android.support.aibot.model.BotMessage
import org.wordpress.android.support.aibot.util.formatRelativeTime
import org.wordpress.android.support.aibot.util.generateSampleBotConversations
import org.wordpress.android.ui.compose.theme.AppThemeM3

private const val PAGINATION_TRIGGER_THRESHOLD = 4

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AIBotConversationDetailScreen(
snackbarHostState: SnackbarHostState,
conversation: BotConversation,
isLoading: Boolean,
isBotTyping: Boolean,
isLoadingOlderMessages: Boolean,
hasMorePages: Boolean,
canSendMessage: Boolean,
userName: String,
onBackClick: () -> Unit,
onSendMessage: (String) -> Unit
onSendMessage: (String) -> Unit,
onLoadOlderMessages: () -> Unit
) {
var messageText by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()

// Scroll to bottom when conversation changes or messages are added or typing state changes
LaunchedEffect(conversation.id, conversation.messages.size, isBotTyping) {
if (conversation.messages.isNotEmpty() || isBotTyping) {
coroutineScope.launch {
// +2 for welcome header and spacer, +1 if typing indicator is showing
val itemCount = conversation.messages.size + 2 + if (isBotTyping) 1 else 0
listState.animateScrollToItem(itemCount)
}

// Scroll to bottom when new messages are added at the end (not when loading older messages at the beginning)
// Only scroll to bottom when:
// 1. The last message changes (new message added at the end)
// 2. Bot starts typing
// 3. We're not loading older messages (which adds messages at the beginning)
LaunchedEffect(conversation.id, conversation.messages.lastOrNull()?.id, isBotTyping) {
if ((conversation.messages.isNotEmpty() || isBotTyping) && !isLoadingOlderMessages) {
listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1)
}
}

// Detect when user scrolls near the top to load older messages
LaunchedEffect(listState, isLoadingOlderMessages, isLoading, hasMorePages) {
snapshotFlow { listState.firstVisibleItemIndex }
.collect { firstVisibleIndex ->
val shouldLoadMore = !isLoadingOlderMessages && firstVisibleIndex <= PAGINATION_TRIGGER_THRESHOLD

if (shouldLoadMore && !isLoading && hasMorePages) {
onLoadOlderMessages()
}
}
}

val resources = LocalResources.current

Scaffold(
Expand Down Expand Up @@ -128,8 +147,25 @@ fun AIBotConversationDetailScreen(
state = listState,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
WelcomeHeader(userName)
// Show loading indicator at top when loading older messages
if (isLoadingOlderMessages) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}

// Only show welcome header when we're at the beginning (no more pages to load)
if (!hasMorePages) {
item {
WelcomeHeader(userName)
}
}

// Key ensures the items recompose when messages change
Expand Down Expand Up @@ -163,10 +199,17 @@ fun AIBotConversationDetailScreen(

@Composable
private fun WelcomeHeader(userName: String) {
val greeting = stringResource(R.string.ai_bot_welcome_greeting, userName)
val message = stringResource(R.string.ai_bot_welcome_message)
val welcomeDescription = "$greeting. $message"

Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
.padding(vertical = 8.dp)
.clearAndSetSemantics {
contentDescription = welcomeDescription
},
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
Expand All @@ -188,7 +231,8 @@ private fun WelcomeHeader(userName: String) {
text = stringResource(R.string.ai_bot_welcome_greeting, userName),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.semantics { heading() }
)

Text(
Expand All @@ -209,6 +253,7 @@ private fun ChatInputBar(
onSendClick: () -> Unit
) {
val canSend = messageText.isNotBlank() && canSendMessage
val messageInputLabel = stringResource(R.string.ai_bot_message_input_placeholder)

Row(
modifier = Modifier
Expand All @@ -221,8 +266,10 @@ private fun ChatInputBar(
OutlinedTextField(
value = messageText,
onValueChange = onMessageTextChange,
modifier = Modifier.weight(1f),
placeholder = { Text(stringResource(R.string.ai_bot_message_input_placeholder)) },
modifier = Modifier
.weight(1f)
.semantics { contentDescription = messageInputLabel },
placeholder = { Text(messageInputLabel) },
maxLines = 4,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences)
)
Expand All @@ -246,6 +293,10 @@ private fun ChatInputBar(

@Composable
private fun MessageBubble(message: BotMessage, resources: android.content.res.Resources) {
val timestamp = formatRelativeTime(message.date, resources)
val author = stringResource(if (message.isWrittenByUser) R.string.ai_bot_you else R.string.ai_bot_support_bot)
val messageDescription = "$author, $timestamp. ${message.formattedText}"

Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (message.isWrittenByUser) {
Expand All @@ -271,22 +322,26 @@ private fun MessageBubble(message: BotMessage, resources: android.content.res.Re
)
)
.padding(12.dp)
.clearAndSetSemantics {
contentDescription = messageDescription
}
) {
Column {
Text(
text = message.text,
style = MaterialTheme.typography.bodyMedium,
color = if (message.isWrittenByUser) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
text = message.formattedText,
style = MaterialTheme.typography.bodyMedium.copy(
color = if (message.isWrittenByUser) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
)

Spacer(modifier = Modifier.height(4.dp))

Text(
text = formatRelativeTime(message.date, resources),
text = timestamp,
style = MaterialTheme.typography.bodySmall,
color = if (message.isWrittenByUser) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
Expand Down Expand Up @@ -317,6 +372,7 @@ private fun TypingIndicatorBubble() {
)
)
.padding(16.dp)
.semantics { contentDescription = "AI Bot is typing" }
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
Expand Down Expand Up @@ -368,9 +424,12 @@ private fun ConversationDetailScreenPreview() {
conversation = sampleConversation,
isLoading = false,
isBotTyping = false,
isLoadingOlderMessages = false,
hasMorePages = false,
canSendMessage = true,
onBackClick = { },
onSendMessage = { }
onSendMessage = { },
onLoadOlderMessages = { }
)
}
}
Expand All @@ -388,9 +447,12 @@ private fun ConversationDetailScreenPreviewDark() {
conversation = sampleConversation,
isLoading = false,
isBotTyping = false,
isLoadingOlderMessages = false,
hasMorePages = false,
canSendMessage = true,
onBackClick = { },
onSendMessage = { }
onSendMessage = { },
onLoadOlderMessages = { }
)
}
}
Expand All @@ -408,9 +470,12 @@ private fun ConversationDetailScreenWordPressPreview() {
conversation = sampleConversation,
isLoading = false,
isBotTyping = false,
isLoadingOlderMessages = false,
hasMorePages = false,
canSendMessage = true,
onBackClick = { },
onSendMessage = { }
onSendMessage = { },
onLoadOlderMessages = { }
)
}
}
Expand All @@ -428,9 +493,12 @@ private fun ConversationDetailScreenPreviewWordPressDark() {
conversation = sampleConversation,
isLoading = false,
isBotTyping = false,
isLoadingOlderMessages = false,
hasMorePages = false,
canSendMessage = true,
onBackClick = { },
onSendMessage = { }
onSendMessage = { },
onLoadOlderMessages = { }
)
}
}
Loading