From f765891280b9c718cf0152c82b287ee7b5cad4ff Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 13:48:26 +0100 Subject: [PATCH 01/12] fix(assistant): chat task flow Signed-off-by: alperozturk96 # Conflicts: # app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt --- .../client/assistant/AssistantScreen.kt | 74 +++-- .../client/assistant/AssistantViewModel.kt | 105 +------ .../client/assistant/chat/ChatContent.kt | 270 +++++++++++++----- .../client/assistant/chat/ChatErrorType.kt | 14 + .../client/assistant/chat/ChatUIState.kt | 29 ++ .../client/assistant/chat/ChatViewModel.kt | 178 ++++++++++++ .../conversation/ConversationViewModel.kt | 9 + .../ui/composeActivity/ComposeActivity.kt | 5 +- app/src/main/res/values/strings.xml | 4 +- 9 files changed, 487 insertions(+), 201 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/assistant/chat/ChatErrorType.kt create mode 100644 app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt create mode 100644 app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index 863a6346b687..fe4e39799275 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -9,7 +9,9 @@ package com.nextcloud.client.assistant import android.app.Activity import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -23,8 +25,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -55,6 +59,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nextcloud.client.assistant.chat.ChatContent +import com.nextcloud.client.assistant.chat.ChatViewModel import com.nextcloud.client.assistant.conversation.ConversationScreen import com.nextcloud.client.assistant.conversation.ConversationViewModel import com.nextcloud.client.assistant.conversation.repository.MockConversationRemoteRepository @@ -92,11 +97,13 @@ private const val PULL_TO_REFRESH_DELAY = 1500L fun AssistantScreen( composeViewModel: ComposeViewModel, viewModel: AssistantViewModel, + chatViewModel: ChatViewModel, conversationViewModel: ConversationViewModel, capability: OCCapability, activity: Activity ) { val selectedText by composeViewModel.selectedText.collectAsState() + val sessionTitle by chatViewModel.sessionTitle.collectAsState() val sessionId by viewModel.sessionId.collectAsState() val messageId by viewModel.snackbarMessageId.collectAsState() val screenOverlayState by viewModel.screenOverlayState.collectAsState() @@ -140,12 +147,8 @@ fun AssistantScreen( } } - LaunchedEffect(sessionId) { + LaunchedEffect(Unit) { viewModel.startPolling(sessionId) - - sessionId?.let { - viewModel.fetchChatMessages(it) - } } DisposableEffect(Unit) { @@ -165,7 +168,7 @@ fun AssistantScreen( pagerState.scrollToPage(AssistantPage.Content.id) } }, openChat = { newSessionId -> - viewModel.initSessionId(newSessionId) + chatViewModel.selectConversation(newSessionId) taskTypes.getChat()?.let { chatTaskType -> viewModel.selectTaskType(chatTaskType) } @@ -184,9 +187,9 @@ fun AssistantScreen( scope.launch { delay(PULL_TO_REFRESH_DELAY) - val newSessionId = sessionId - if (newSessionId != null) { - viewModel.fetchChatMessages(newSessionId) + val currentSessionId = sessionId + if (currentSessionId != null) { + chatViewModel.selectConversation(currentSessionId) } else { viewModel.fetchTaskList() } @@ -194,14 +197,35 @@ fun AssistantScreen( } ), topBar = { - taskTypes?.let { - TaskTypesRow(selectedTaskType, data = it, selectTaskType = { task -> - viewModel.selectTaskType(task) - }, navigateToConversationList = { - scope.launch { - pagerState.scrollToPage(AssistantPage.Conversation.id) + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + taskTypes?.let { + TaskTypesRow(selectedTaskType, data = it, selectTaskType = { task -> + viewModel.selectTaskType(task) + }, navigateToConversationList = { + scope.launch { + pagerState.scrollToPage(AssistantPage.Conversation.id) + } + }) + } + + if (selectedTaskType?.isChat() == true && sessionTitle != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = sessionTitle!!, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1 + ) + + Spacer(modifier = Modifier.height(8.dp)) } - }) + } } }, bottomBar = { @@ -209,7 +233,8 @@ fun AssistantScreen( InputBar( sessionId, selectedTaskType, - viewModel + viewModel, + chatViewModel ) } }, @@ -257,7 +282,7 @@ fun AssistantScreen( AssistantScreenState.ChatContent -> { ChatContent( - viewModel = viewModel, + chatViewModel = chatViewModel, modifier = Modifier.padding(paddingValues) ) } @@ -301,7 +326,12 @@ fun AssistantScreen( @Suppress("LongMethod") @Composable -private fun InputBar(sessionId: Long?, selectedTaskType: TaskTypeData?, viewModel: AssistantViewModel) { +private fun InputBar( + sessionId: Long?, + selectedTaskType: TaskTypeData?, + viewModel: AssistantViewModel, + chatViewModel: ChatViewModel +) { val scope = rememberCoroutineScope() val text by viewModel.inputBarText.collectAsState() @@ -349,9 +379,9 @@ private fun InputBar(sessionId: Long?, selectedTaskType: TaskTypeData?, viewMode val taskType = selectedTaskType ?: return@IconButton if (taskType.isChat()) { if (sessionId != null) { - viewModel.sendChatMessage(content = text, sessionId) + chatViewModel.sendMessage(content = text, sessionId = sessionId) } else { - viewModel.createConversation(text) + chatViewModel.startNewConversation(content = text) } } else { viewModel.createTask(input = text, taskType = taskType) @@ -498,6 +528,7 @@ private fun AssistantScreenPreview() { composeViewModel = ComposeViewModel(), conversationViewModel = getMockConversationViewModel(), viewModel = getMockAssistantViewModel(false), + chatViewModel = ChatViewModel(MockAssistantRemoteRepository()), activity = ComposeActivity(), capability = OCCapability().apply { versionMayor = 30 @@ -517,6 +548,7 @@ private fun AssistantEmptyScreenPreview() { composeViewModel = ComposeViewModel(), conversationViewModel = getMockConversationViewModel(), viewModel = getMockAssistantViewModel(true), + chatViewModel = ChatViewModel(MockAssistantRemoteRepository()), activity = ComposeActivity(), capability = OCCapability().apply { versionMayor = 30 diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt index 8134df9fbe58..989bd26a1c97 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt @@ -14,11 +14,8 @@ import com.nextcloud.client.assistant.model.ScreenOverlayState import com.nextcloud.client.assistant.repository.local.AssistantLocalRepository import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository import com.nextcloud.utils.TimeConstants.MILLIS_PER_SECOND -import com.nextcloud.utils.extensions.isHuman import com.owncloud.android.R import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage -import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest import com.owncloud.android.lib.resources.assistant.v2.model.Task import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData import kotlinx.coroutines.Dispatchers @@ -75,14 +72,7 @@ class AssistantViewModel( private val _filteredTaskList = MutableStateFlow?>(null) val filteredTaskList: StateFlow?> = _filteredTaskList - private val _chatMessages = MutableStateFlow>(listOf()) - val chatMessages: StateFlow> = _chatMessages - - private val _isAssistantAnswering = MutableStateFlow(false) - val isAssistantAnswering: StateFlow = _isAssistantAnswering - private var pollingJob: Job? = null - private var currentChatTaskId: String? = null init { observeScreenState() @@ -97,20 +87,8 @@ class AssistantViewModel( try { while (isActive) { delay(POLLING_INTERVAL_MS) - val taskType = _selectedTaskType.value ?: continue - - if (taskType.isChat() && sessionId != null) { - Log_OC.d(TAG, "Polling chat messages, sessionId: $sessionId") - - if (currentChatTaskId == null) { - remoteRepository.generateSession(sessionId.toString())?.let { - currentChatTaskId = it.taskId.toString() - } - } - - fetchNewChatMessage(sessionId) - } else if (!taskType.isChat()) { + if (!taskType.isChat()) { Log_OC.d(TAG, "Polling task list") pollTaskList() } @@ -143,37 +121,13 @@ class AssistantViewModel( } } - fun fetchNewChatMessage(sessionId: Long) = viewModelScope.launch(Dispatchers.IO) { - val taskId = currentChatTaskId ?: return@launch - val newMessage = remoteRepository.checkGeneration(taskId, sessionId.toString()) ?: return@launch - - _chatMessages.update { current -> - val messageExists = current.any { - it.id == newMessage.id || - (it.timestamp == newMessage.timestamp && it.content == newMessage.content) - } - - if (messageExists) { - current - } else { - if (!newMessage.isHuman()) { - _isAssistantAnswering.update { - false - } - } - current + newMessage - } - } - } - private fun observeScreenState() { viewModelScope.launch { combine( selectedTask, _selectedTaskType, - _chatMessages, _filteredTaskList - ) { selectedTask, selectedTaskType, chats, tasks -> + ) { selectedTask, selectedTaskType, tasks -> val isChat = selectedTaskType?.isChat() == true val isTranslation = selectedTaskType?.isTranslate() == true && selectedTask?.isTranslate() == true @@ -183,11 +137,9 @@ class AssistantViewModel( isTranslation -> AssistantScreenState.Translation(selectedTask) - isChat && chats.isEmpty() -> AssistantScreenState.emptyChatList() - isChat -> AssistantScreenState.ChatContent - !isChat && (tasks == null || tasks.isEmpty()) -> AssistantScreenState.emptyTaskList() + !isChat && tasks.isNullOrEmpty() -> AssistantScreenState.emptyTaskList() else -> { if (!_isTranslationTask.value) { @@ -203,48 +155,6 @@ class AssistantViewModel( } } - // region chat - fun sendChatMessage(content: String, sessionId: Long) = viewModelScope.launch(Dispatchers.IO) { - val request = ChatMessageRequest( - sessionId = sessionId.toString(), - role = "human", - content = content, - timestamp = System.currentTimeMillis() / MILLIS_PER_SECOND, - firstHumanMessage = _chatMessages.value.isEmpty() - ) - - remoteRepository.sendChatMessage(request)?.let { newMessage -> - _chatMessages.update { messages -> - messages + newMessage - } - _isAssistantAnswering.update { - true - } - } ?: updateSnackbarMessage(R.string.assistant_screen_chat_create_error) - } - - fun fetchChatMessages(sessionId: Long) = viewModelScope.launch(Dispatchers.IO) { - remoteRepository.fetchChatMessages(sessionId)?.let { messageList -> - _chatMessages.update { - messageList - } - } ?: updateSnackbarMessage(R.string.assistant_screen_chat_fetch_error) - } - - fun createConversation(title: String) = viewModelScope.launch(Dispatchers.IO) { - remoteRepository.createConversation(title)?.let { result -> - initSessionId(result.session.id) - sendChatMessage(title, result.session.id) - } - } - - fun initSessionId(value: Long) { - Log_OC.d(TAG, "session id updated: $value") - currentChatTaskId = null - _sessionId.update { value } - } - // endregion - // region task fun createTask(input: String, taskType: TaskTypeData) = viewModelScope.launch(Dispatchers.IO) { val result = remoteRepository.createTask(input, taskType) @@ -273,15 +183,6 @@ class AssistantViewModel( if (!task.isChat()) { fetchTaskList() - return - } - - // only task chat type needs to be handled differently - val sessionId = _sessionId.value ?: return - if (_chatMessages.value.isEmpty()) { - fetchChatMessages(sessionId) - } else { - fetchNewChatMessage(sessionId) } } diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt index f88f54e2a176..c8ef16b36a2b 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt @@ -33,6 +33,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -47,12 +51,11 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nextcloud.client.assistant.AssistantViewModel import com.nextcloud.utils.TimeConstants -import com.nextcloud.utils.extensions.isHuman import com.nextcloud.utils.extensions.time import com.owncloud.android.R import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage @@ -63,25 +66,90 @@ private val CHAT_BUBBLE_CORNER_RADIUS = 8.dp private val ASSISTANT_ICON_SIZE = 40.dp @Composable -fun ChatContent(viewModel: AssistantViewModel, modifier: Modifier = Modifier) { - val chatMessages by viewModel.chatMessages.collectAsState() - val isAssistantAnswering by viewModel.isAssistantAnswering.collectAsState() +fun ChatContent(chatViewModel: ChatViewModel, modifier: Modifier = Modifier) { + val uiState by chatViewModel.uiState.collectAsState() val listState = rememberLazyListState() - LaunchedEffect(chatMessages) { - listState.animateScrollToItem(listState.layoutInfo.totalItemsCount) + val messages = uiState.messages() + + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(messages.size - 1) + } } + when (uiState) { + is ChatUIState.Loading -> { + ChatLoadingContent(modifier) + } + + is ChatUIState.Empty -> { + ChatEmptyContent(modifier) + } + + is ChatUIState.Error -> { + val errorState = uiState as ChatUIState.Error + ChatMessageList( + messages = messages, + showTypingIndicator = false, + modifier = modifier, + listState = listState, + bottomSlot = { + ChatErrorBanner(errorState.errorType) + } + ) + } + + is ChatUIState.RetryAvailable -> { + ChatMessageList( + messages = messages, + showTypingIndicator = false, + modifier = modifier, + listState = listState, + bottomSlot = { + RetryButton(onClick = { chatViewModel.retryResponseGeneration() }) + } + ) + } + + is ChatUIState.Thinking -> { + ChatMessageList( + messages = messages, + showTypingIndicator = true, + modifier = modifier, + listState = listState + ) + } + + is ChatUIState.Content, + is ChatUIState.Sending -> { + ChatMessageList( + messages = messages, + showTypingIndicator = false, + modifier = modifier, + listState = listState + ) + } + } +} + +@Composable +private fun ChatMessageList( + messages: List, + showTypingIndicator: Boolean, + modifier: Modifier = Modifier, + listState: androidx.compose.foundation.lazy.LazyListState, + bottomSlot: (@Composable () -> Unit)? = null +) { LazyColumn( modifier = modifier .fillMaxSize() .padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.Bottom, - reverseLayout = false, state = listState ) { - items(chatMessages, key = { it.id }) { message -> - if (message.isHuman()) { + items(messages, key = { it.id }) { message -> + if (message.role == "human") { UserMessageItem(message) } else { AssistantMessageItem(message) @@ -89,12 +157,102 @@ fun ChatContent(viewModel: AssistantViewModel, modifier: Modifier = Modifier) { Spacer(modifier = Modifier.height(8.dp)) } - if (isAssistantAnswering) { + if (showTypingIndicator) { item { AssistantTypingIndicator() Spacer(modifier = Modifier.height(8.dp)) } } + + bottomSlot?.let { + item { it() } + } + } +} + +@Composable +private fun ChatLoadingContent(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ChatEmptyContent(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_assistant), + contentDescription = null, + tint = colorResource(R.color.secondary_text_color), + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.assistant_screen_empty_content_title), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = colorResource(R.color.secondary_text_color) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.assistant_screen_empty_content_description), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = colorResource(R.color.secondary_text_color) + ) + } + } +} + +@Composable +private fun ChatErrorBanner(errorType: ChatErrorType) { + val messageRes = when (errorType) { + ChatErrorType.LoadMessages -> R.string.assistant_screen_chat_fetch_error + ChatErrorType.SendMessage -> R.string.assistant_screen_chat_create_error + ChatErrorType.GenerateResponse -> R.string.assistant_screen_chat_generate_error + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(12.dp) + ) { + Text( + text = stringResource(messageRes), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun RetryButton(onClick: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Button(onClick = onClick) { + Text(text = stringResource(R.string.assistant_screen_retry_response_generation)) + } } } @@ -106,11 +264,8 @@ private fun AssistantTypingIndicator() { .fillMaxWidth(), contentAlignment = Alignment.CenterStart ) { - Row( - verticalAlignment = Alignment.Bottom - ) { + Row(verticalAlignment = Alignment.Bottom) { AnimatedAssistantIcon() - Box( modifier = Modifier .padding(start = 8.dp, end = 16.dp) @@ -147,9 +302,7 @@ private fun AnimatedAssistantIcon() { modifier = Modifier .size(ASSISTANT_ICON_SIZE) .clip(CircleShape) - .background( - color = colorResource(R.color.bg_message_bubble) - ), + .background(color = colorResource(R.color.bg_message_bubble)), contentAlignment = Alignment.Center ) { Image( @@ -157,8 +310,7 @@ private fun AnimatedAssistantIcon() { contentDescription = null, contentScale = ContentScale.Crop, alignment = Alignment.Center, - modifier = Modifier - .scale(scale) + modifier = Modifier.scale(scale) ) } } @@ -166,7 +318,6 @@ private fun AnimatedAssistantIcon() { @Composable private fun TypingAnimation() { val infiniteTransition = rememberInfiniteTransition(label = "typing_animation") - val alpha by infiniteTransition.animateFloat( initialValue = 0.3f, targetValue = 1f, @@ -177,10 +328,7 @@ private fun TypingAnimation() { label = "typing_alpha" ) - Column( - modifier = Modifier.padding(8.dp), - horizontalAlignment = Alignment.Start - ) { + Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.Start) { Text( text = stringResource(R.string.assistant_thinking), style = TextStyle( @@ -199,16 +347,12 @@ private fun AssistantMessageItem(message: ChatMessage) { .fillMaxWidth(), contentAlignment = Alignment.CenterStart ) { - Row( - verticalAlignment = Alignment.Bottom - ) { + Row(verticalAlignment = Alignment.Bottom) { Box( modifier = Modifier .size(ASSISTANT_ICON_SIZE) .clip(CircleShape) - .background( - color = colorResource(R.color.bg_message_bubble) - ), + .background(color = colorResource(R.color.bg_message_bubble)), contentAlignment = Alignment.Center ) { Image( @@ -218,7 +362,6 @@ private fun AssistantMessageItem(message: ChatMessage) { alignment = Alignment.Center ) } - Box( modifier = Modifier .padding(start = 8.dp, end = 16.dp) @@ -230,9 +373,7 @@ private fun AssistantMessageItem(message: ChatMessage) { bottomEnd = CHAT_BUBBLE_CORNER_RADIUS ) ) - .background( - color = colorResource(R.color.bg_message_bubble) - ) + .background(color = colorResource(R.color.bg_message_bubble)) ) { MessageTextItem(message) } @@ -248,48 +389,35 @@ private fun UserMessageItem(message: ChatMessage) { .fillMaxWidth(), contentAlignment = Alignment.CenterEnd ) { - Row( - verticalAlignment = Alignment.Bottom - ) { - Box( - modifier = Modifier - .padding(start = 16.dp, end = 8.dp) - .defaultMinSize(minHeight = MIN_CHAT_HEIGHT) - .clip( - RoundedCornerShape( - topEnd = CHAT_BUBBLE_CORNER_RADIUS, - topStart = CHAT_BUBBLE_CORNER_RADIUS, - bottomStart = CHAT_BUBBLE_CORNER_RADIUS - ) + Box( + modifier = Modifier + .padding(start = 16.dp, end = 8.dp) + .defaultMinSize(minHeight = MIN_CHAT_HEIGHT) + .clip( + RoundedCornerShape( + topEnd = CHAT_BUBBLE_CORNER_RADIUS, + topStart = CHAT_BUBBLE_CORNER_RADIUS, + bottomStart = CHAT_BUBBLE_CORNER_RADIUS ) - .background(color = colorResource(R.color.bg_message_bubble)) - ) { - MessageTextItem(message) - } + ) + .background(color = colorResource(R.color.bg_message_bubble)) + ) { + MessageTextItem(message) } } } @Composable private fun MessageTextItem(message: ChatMessage) { - Column( - modifier = Modifier.padding(8.dp), - horizontalAlignment = Alignment.Start - ) { + Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.Start) { Text( text = message.content, - style = TextStyle( - color = colorResource(R.color.text_color), - fontSize = 16.sp - ) + style = TextStyle(color = colorResource(R.color.text_color), fontSize = 16.sp) ) Spacer(modifier = Modifier.height(4.dp)) Text( text = message.time(), - style = TextStyle( - color = colorResource(R.color.secondary_text_color), - fontSize = 12.sp - ), + style = TextStyle(color = colorResource(R.color.secondary_text_color), fontSize = 12.sp), modifier = Modifier.align(Alignment.End) ) } @@ -313,18 +441,8 @@ private fun MessageTextItemPreview() { id = 2, sessionId = 101, role = "assistant", - content = "I'm good! Here’s a message from yesterday.", - timestamp = Instant.now().minusSeconds(86_400).epochSecond, // 1 day ago - ocpTaskId = null, - sources = "", - attachments = emptyList() - ), - ChatMessage( - id = 3, - sessionId = 101, - role = "human", - content = "And an older one from last week.", - timestamp = Instant.now().minusSeconds(7 * 86_400).epochSecond, // 7 days ago + content = "I'm good! Here's a message from yesterday.", + timestamp = Instant.now().minusSeconds(86_400).epochSecond, ocpTaskId = null, sources = "", attachments = emptyList() diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatErrorType.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatErrorType.kt new file mode 100644 index 000000000000..9bab64242fc8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatErrorType.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.chat + +sealed class ChatErrorType { + data object LoadMessages : ChatErrorType() + data object SendMessage : ChatErrorType() + data object GenerateResponse : ChatErrorType() +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt new file mode 100644 index 000000000000..e13efec37f8a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.chat + +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage + +sealed class ChatUIState { + data object Loading : ChatUIState() + data object Empty : ChatUIState() + data class Content(val messages: List) : ChatUIState() + data class Sending(val messages: List) : ChatUIState() + data class Thinking(val messages: List) : ChatUIState() + data class RetryAvailable(val messages: List) : ChatUIState() + data class Error(val messages: List, val errorType: ChatErrorType) : ChatUIState() +} + +fun ChatUIState.messages(): List = when (this) { + is ChatUIState.Content -> messages + is ChatUIState.Sending -> messages + is ChatUIState.Thinking -> messages + is ChatUIState.RetryAvailable -> messages + is ChatUIState.Error -> messages + is ChatUIState.Loading, is ChatUIState.Empty -> emptyList() +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt new file mode 100644 index 000000000000..8a4c81d39947 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt @@ -0,0 +1,178 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.chat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository +import com.nextcloud.utils.TimeConstants.MILLIS_PER_SECOND +import com.nextcloud.utils.extensions.isHuman +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage +import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : ViewModel() { + + companion object { + private const val POLLING_INTERVAL_MS = 4_000L + } + + private val _uiState = MutableStateFlow(ChatUIState.Empty) + val uiState: StateFlow = _uiState + + private val _sessionTitle = MutableStateFlow(null) + val sessionTitle: StateFlow = _sessionTitle + + private val _sessionId = MutableStateFlow(null) + val sessionId: StateFlow = _sessionId + + private var currentMessages: List = emptyList() + private var currentChatTaskId: String? = null + private var pollingJob: Job? = null + + fun selectConversation(sessionId: Long) { + _sessionId.update { sessionId } + currentChatTaskId = null + + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { ChatUIState.Loading } + + fetchAllMessages(sessionId) + + val session = remoteRepository.checkSession(sessionId.toString()) + currentChatTaskId = session?.taskId?.toString() + + val lastMessageIsHuman = currentMessages.lastOrNull()?.isHuman() == true + + _sessionTitle.update { + session?.sessionTitle + } + + when { + currentChatTaskId != null && currentChatTaskId != "0" -> startPolling(sessionId) + lastMessageIsHuman -> _uiState.update { ChatUIState.RetryAvailable(currentMessages) } + else -> _uiState.update { ChatUIState.Content(currentMessages) } + } + } + } + + fun startPolling(sessionId: Long) { + stopPolling() + _uiState.update { ChatUIState.Thinking(currentMessages) } + + pollingJob = viewModelScope.launch(Dispatchers.IO) { + while (isActive) { + delay(POLLING_INTERVAL_MS) + fetchNewChatMessage(sessionId) + } + } + } + + fun stopPolling() { + pollingJob?.cancel() + pollingJob = null + } + + private suspend fun fetchAllMessages(sessionId: Long) { + val messages = remoteRepository.fetchChatMessages(sessionId) + if (messages != null) { + currentMessages = messages + _uiState.update { + if (messages.isEmpty()) ChatUIState.Empty else ChatUIState.Content(messages) + } + } else { + _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.LoadMessages) } + } + } + + private suspend fun fetchNewChatMessage(sessionId: Long) { + val taskId = currentChatTaskId ?: return + val newMessage = remoteRepository.checkGeneration(taskId, sessionId.toString()) ?: run { + stopPolling() + _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.GenerateResponse) } + return + } + + val alreadyExists = currentMessages.any { + it.id == newMessage.id || + (it.timestamp == newMessage.timestamp && it.content == newMessage.content) + } + + if (!alreadyExists && !newMessage.isHuman()) { + currentMessages = currentMessages + newMessage + stopPolling() + _uiState.update { ChatUIState.Content(currentMessages) } + } + } + + fun sendMessage(content: String, sessionId: Long) { + val request = ChatMessageRequest( + sessionId = sessionId.toString(), + role = "human", + content = content, + timestamp = System.currentTimeMillis() / MILLIS_PER_SECOND, + firstHumanMessage = currentMessages.isEmpty() + ) + + _uiState.update { ChatUIState.Sending(currentMessages) } + + viewModelScope.launch(Dispatchers.IO) { + val sentMessage = remoteRepository.sendChatMessage(request) + if (sentMessage != null) { + currentMessages = currentMessages + sentMessage + stopPolling() + generateSession(sessionId) + startPolling(sessionId) + } else { + _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.SendMessage) } + } + } + } + + private suspend fun generateSession(sessionId: Long) { + remoteRepository.generateSession(sessionId.toString())?.let { + currentChatTaskId = it.taskId.toString() + } + } + + fun retryResponseGeneration() { + val sessionId = _sessionId.value ?: return + viewModelScope.launch(Dispatchers.IO) { + generateSession(sessionId) + startPolling(sessionId) + } + } + + fun startNewConversation(content: String) { + _uiState.update { ChatUIState.Sending(currentMessages) } + + viewModelScope.launch(Dispatchers.IO) { + val result = remoteRepository.createConversation(content) ?: run { + _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.SendMessage) } + return@launch + } + + val newSessionId = result.session.id + _sessionId.update { newSessionId } + currentChatTaskId = null + sendMessage(content, newSessionId) + } + } + + override fun onCleared() { + super.onCleared() + stopPolling() + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt index 68e4ff6358eb..062d07c55804 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt @@ -23,6 +23,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class ConversationViewModel(private val remoteRepository: ConversationRemoteRepository) : ViewModel() { + private val _selectedConversationId = MutableStateFlow(null) + val selectedConversationId: StateFlow = _selectedConversationId + private val _errorMessageId = MutableStateFlow(null) val errorMessageId: StateFlow = _errorMessageId @@ -32,6 +35,12 @@ class ConversationViewModel(private val remoteRepository: ConversationRemoteRepo private val _conversations = MutableStateFlow>(listOf()) val conversations: StateFlow> = _conversations.asStateFlow() + fun selectConversation(value: Long?) { + _selectedConversationId.update { + value + } + } + fun fetchConversations() { _screenState.update { ConversationScreenState.Loading diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt index dfa429d2f15a..f2e28c31b48e 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.nextcloud.client.assistant.AssistantScreen import com.nextcloud.client.assistant.AssistantViewModel +import com.nextcloud.client.assistant.chat.ChatViewModel import com.nextcloud.client.assistant.conversation.ConversationViewModel import com.nextcloud.client.assistant.conversation.repository.ConversationRemoteRepositoryImpl import com.nextcloud.client.assistant.repository.local.AssistantLocalRepositoryImpl @@ -129,15 +130,17 @@ class ComposeActivity : DrawerActivity() { val dao = NextcloudDatabase.instance().assistantDao() val sessionId = (currentScreen as? ComposeDestination.AssistantScreen)?.sessionId val client = nextcloudClient ?: return + val remoteRepository = AssistantRemoteRepositoryImpl(client, capabilities) AssistantScreen( composeViewModel = composeViewModel, viewModel = AssistantViewModel( accountName = userAccountManager.user.accountName, - remoteRepository = AssistantRemoteRepositoryImpl(client, capabilities), + remoteRepository = remoteRepository, localRepository = AssistantLocalRepositoryImpl(dao), sessionIdArg = sessionId ), + chatViewModel = ChatViewModel(remoteRepository), conversationViewModel = ConversationViewModel( remoteRepository = ConversationRemoteRepositoryImpl(client) ), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1965da59731..30cb37582696 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -59,6 +59,9 @@ The task output is not ready yet. Failed to send a message Failed to fetch chat messages + Failed to generate response + Retry response generation + Failed to fetch chat messages Unable to fetch task list, please check your internet connection. Task list is empty. Check assistant app configuration. Delete task @@ -107,7 +110,6 @@ Hello there! What can I help you with today? Try sending a message to spark a conversation. - Recommended files Assistant From a1114817ec63f08e9174c45987b74ce7f849483c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 13:57:09 +0100 Subject: [PATCH 02/12] fix chat creation Signed-off-by: alperozturk96 --- .../client/assistant/chat/ChatViewModel.kt | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt index 8a4c81d39947..df80d4f420f7 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt @@ -118,6 +118,13 @@ class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : V } fun sendMessage(content: String, sessionId: Long) { + _uiState.update { ChatUIState.Sending(currentMessages) } + viewModelScope.launch(Dispatchers.IO) { + sendMessageInternal(content, sessionId) + } + } + + private suspend fun sendMessageInternal(content: String, sessionId: Long) { val request = ChatMessageRequest( sessionId = sessionId.toString(), role = "human", @@ -126,18 +133,14 @@ class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : V firstHumanMessage = currentMessages.isEmpty() ) - _uiState.update { ChatUIState.Sending(currentMessages) } - - viewModelScope.launch(Dispatchers.IO) { - val sentMessage = remoteRepository.sendChatMessage(request) - if (sentMessage != null) { - currentMessages = currentMessages + sentMessage - stopPolling() - generateSession(sessionId) - startPolling(sessionId) - } else { - _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.SendMessage) } - } + val sentMessage = remoteRepository.sendChatMessage(request) + if (sentMessage != null) { + currentMessages = currentMessages + sentMessage + stopPolling() + generateSession(sessionId) + startPolling(sessionId) + } else { + _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.SendMessage) } } } @@ -156,6 +159,11 @@ class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : V } fun startNewConversation(content: String) { + if (_sessionId.value != null) { + sendMessage(content, _sessionId.value!!) + return + } + _uiState.update { ChatUIState.Sending(currentMessages) } viewModelScope.launch(Dispatchers.IO) { @@ -167,7 +175,7 @@ class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : V val newSessionId = result.session.id _sessionId.update { newSessionId } currentChatTaskId = null - sendMessage(content, newSessionId) + sendMessageInternal(content, newSessionId) } } From 64c3d0efbafcda3e089d33cdf893373b065f83be Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 14:14:52 +0100 Subject: [PATCH 03/12] fix chat response generation Signed-off-by: alperozturk96 --- .../com/nextcloud/client/assistant/chat/ChatViewModel.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt index df80d4f420f7..bb2b80fa1d28 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt @@ -99,11 +99,7 @@ class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : V private suspend fun fetchNewChatMessage(sessionId: Long) { val taskId = currentChatTaskId ?: return - val newMessage = remoteRepository.checkGeneration(taskId, sessionId.toString()) ?: run { - stopPolling() - _uiState.update { ChatUIState.Error(currentMessages, ChatErrorType.GenerateResponse) } - return - } + val newMessage = remoteRepository.checkGeneration(taskId, sessionId.toString()) ?: return val alreadyExists = currentMessages.any { it.id == newMessage.id || From 510672901ed8b834b510be4ceda225e3dd424698 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 14:38:02 +0100 Subject: [PATCH 04/12] fixes Signed-off-by: alperozturk96 --- .../com/nextcloud/client/assistant/chat/ChatViewModel.kt | 8 +++++++- .../client/assistant/conversation/ConversationScreen.kt | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt index bb2b80fa1d28..f7765a7bb1df 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt @@ -63,7 +63,13 @@ class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : V when { currentChatTaskId != null && currentChatTaskId != "0" -> startPolling(sessionId) lastMessageIsHuman -> _uiState.update { ChatUIState.RetryAvailable(currentMessages) } - else -> _uiState.update { ChatUIState.Content(currentMessages) } + else -> _uiState.update { + if (currentMessages.isEmpty()) { + ChatUIState.Empty + } else { + ChatUIState.Content(currentMessages) + } + } } } } diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt index 29e37fabade3..677813929e16 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt @@ -108,6 +108,7 @@ fun ConversationScreen(viewModel: ConversationViewModel, close: () -> Unit, open floatingActionButton = { FloatingActionButton(onClick = { viewModel.createConversation(null, onResult = { + viewModel.selectConversation(null) openChat(it) }) }) { From 2cf019b54d06c83828797bc2399555fefa779609 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 14:53:56 +0100 Subject: [PATCH 05/12] fixes Signed-off-by: alperozturk96 --- .../client/assistant/AssistantScreen.kt | 8 +++---- .../client/assistant/chat/ChatContent.kt | 4 ++-- .../client/assistant/chat/ChatViewModel.kt | 23 +++++++++++++++++-- .../conversation/ConversationViewModel.kt | 4 ++-- .../nextcloud/utils/date/DateFormatPattern.kt | 7 +++++- 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index fe4e39799275..233b5c25ec41 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -11,7 +11,6 @@ import android.app.Activity import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -25,10 +24,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -167,8 +164,9 @@ fun AssistantScreen( scope.launch { pagerState.scrollToPage(AssistantPage.Content.id) } - }, openChat = { newSessionId -> - chatViewModel.selectConversation(newSessionId) + }, openChat = { conversation -> + chatViewModel.updateSessionTitle(conversation.timestamp) + chatViewModel.selectConversation(conversation.id) taskTypes.getChat()?.let { chatTaskType -> viewModel.selectTaskType(chatTaskType) } diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt index c8ef16b36a2b..5468ff420de0 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt @@ -201,9 +201,9 @@ private fun ChatEmptyContent(modifier: Modifier = Modifier) { Text( text = stringResource(R.string.assistant_screen_empty_content_title), - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, - color = colorResource(R.color.secondary_text_color) + color = colorResource(R.color.text_color) ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt index f7765a7bb1df..017a434602af 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt @@ -11,6 +11,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository import com.nextcloud.utils.TimeConstants.MILLIS_PER_SECOND +import com.nextcloud.utils.date.DateFormatPattern +import com.nextcloud.utils.date.DateFormatter import com.nextcloud.utils.extensions.isHuman import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest @@ -56,13 +58,18 @@ class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : V val lastMessageIsHuman = currentMessages.lastOrNull()?.isHuman() == true - _sessionTitle.update { - session?.sessionTitle + // update session title if exists + session?.sessionTitle?.let { + _sessionTitle.update { + it + } } when { currentChatTaskId != null && currentChatTaskId != "0" -> startPolling(sessionId) + lastMessageIsHuman -> _uiState.update { ChatUIState.RetryAvailable(currentMessages) } + else -> _uiState.update { if (currentMessages.isEmpty()) { ChatUIState.Empty @@ -181,6 +188,18 @@ class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : V } } + fun updateSessionTitle(timestamp: Long) { + val newSessionTitle = DateFormatter + .timestampToDateRepresentation( + timestamp, + DateFormatPattern.MonthDayYearTime + ) + + _sessionTitle.update { + newSessionTitle + } + } + override fun onCleared() { super.onCleared() stopPolling() diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt index 062d07c55804..a887f31f5ab5 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt @@ -72,7 +72,7 @@ class ConversationViewModel(private val remoteRepository: ConversationRemoteRepo } } - fun createConversation(title: String?, onResult: (Long) -> Unit) { + fun createConversation(title: String?, onResult: (Conversation) -> Unit) { viewModelScope.launch(Dispatchers.IO) { val timestamp = System.currentTimeMillis().div(MILLIS_PER_SECOND) val newConversation = remoteRepository.createConversation(title, timestamp) @@ -80,7 +80,7 @@ class ConversationViewModel(private val remoteRepository: ConversationRemoteRepo _conversations.update { listOf(newConversation.session) + it } - onResult(newConversation.session.id) + onResult(newConversation.session) } else { _errorMessageId.update { R.string.conversation_screen_create_error_title diff --git a/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt b/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt index a3852d67af25..7324596e3a59 100644 --- a/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt +++ b/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt @@ -16,5 +16,10 @@ enum class DateFormatPattern(val pattern: String) { /** * Aug 3 */ - MonthWithDate("MMM d") + MonthWithDate("MMM d"), + + /** + * March 03, 2026 14:38 + */ + MonthDayYearTime("MMMM dd, yyyy HH:mm") } From ecb7a7cf75fad1e8d4d981746d3b94aff914136f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 5 Mar 2026 09:12:52 +0100 Subject: [PATCH 06/12] fix lint Signed-off-by: alperozturk96 --- app/src/main/res/values/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 30cb37582696..c8040eee2be8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -61,7 +61,6 @@ Failed to fetch chat messages Failed to generate response Retry response generation - Failed to fetch chat messages Unable to fetch task list, please check your internet connection. Task list is empty. Check assistant app configuration. Delete task From 07a74b049b1c8b85c400e20251211ef70ec6270a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 9 Mar 2026 16:49:45 +0100 Subject: [PATCH 07/12] rebase Signed-off-by: alperozturk96 --- .../conversation/ConversationScreen.kt | 103 ++++++++++++------ 1 file changed, 69 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt index 677813929e16..dce86889026e 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt @@ -9,6 +9,7 @@ package com.nextcloud.client.assistant.conversation +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,13 +36,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -52,7 +52,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.nextcloud.client.assistant.conversation.model.ConversationScreenState import com.nextcloud.ui.composeComponents.bottomSheet.MoreActionsBottomSheet import com.owncloud.android.R @@ -61,7 +60,7 @@ import com.owncloud.android.lib.resources.assistant.chat.model.Conversation @OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod") @Composable -fun ConversationScreen(viewModel: ConversationViewModel, close: () -> Unit, openChat: (Long) -> Unit) { +fun ConversationScreen(viewModel: ConversationViewModel, close: () -> Unit, openChat: (Conversation) -> Unit) { val screenState by viewModel.screenState.collectAsState() val errorMessageId by viewModel.errorMessageId.collectAsState() val conversations by viewModel.conversations.collectAsState() @@ -161,9 +160,10 @@ private fun ConversationList( viewModel: ConversationViewModel, conversations: List, modifier: Modifier = Modifier, - openChat: (Long) -> Unit + openChat: (Conversation) -> Unit ) { - var selectedConversationId by remember { mutableLongStateOf(-1L) } + var showConversationActions by remember { mutableStateOf(false) } + val selectedConversationId by viewModel.selectedConversationId.collectAsState() LazyColumn( modifier = modifier @@ -174,18 +174,20 @@ private fun ConversationList( items(conversations) { conversation -> ConversationListItem( conversation = conversation, + isSelected = (conversation.id == selectedConversationId), onClick = { - openChat(conversation.id) + viewModel.selectConversation(conversation.id) + openChat(conversation) }, onLongPressed = { - selectedConversationId = conversation.id + showConversationActions = true } ) Spacer(modifier = Modifier.height(4.dp)) } } - if (selectedConversationId != -1L) { + if (showConversationActions) { val currentId = selectedConversationId val bottomSheetAction = listOf( @@ -195,42 +197,55 @@ private fun ConversationList( ) { val sessionId: String = currentId.toString() viewModel.deleteConversation(sessionId) - selectedConversationId = -1L + showConversationActions = false } ) MoreActionsBottomSheet( actions = bottomSheetAction, - onDismiss = { selectedConversationId = -1L } + onDismiss = { showConversationActions = false } ) } } @Composable -private fun ConversationListItem(conversation: Conversation, onClick: () -> Unit, onLongPressed: () -> Unit) { - Surface( +private fun ConversationListItem( + conversation: Conversation, + isSelected: Boolean, + onClick: () -> Unit, + onLongPressed: () -> Unit +) { + Box( modifier = Modifier .fillMaxWidth() + .height(52.dp) + .background( + if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surface + } + ) .combinedClickable( onClick = onClick, onLongClick = onLongPressed ) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = conversation.titleRepresentation(), - style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = colorResource(R.color.text_color), - modifier = Modifier.weight(1f) - ) - } + Text( + text = conversation.titleRepresentation(), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) { + MaterialTheme.colorScheme.onSurface + } else { + colorResource(R.color.text_color) + }, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) } } @@ -238,15 +253,35 @@ private fun ConversationListItem(conversation: Conversation, onClick: () -> Unit @Composable private fun ConversationListPreview() { Column { - ConversationListItem(Conversation(1L, "User1", "Who is Al Pacino?", 1762847286L, "", null), { - }, { - }) + ConversationListItem( + Conversation( + 1L, + "User1", + "Who is Al Pacino?", + 1762847286L, + "", + null + ), + false, { + }, { + } + ) Spacer(modifier = Modifier.height(8.dp)) - ConversationListItem(Conversation(2L, "User1", "What is JetpackCompose?", 1761847286L, "", null), { - }, { - }) + ConversationListItem( + Conversation( + 2L, + "User1", + "What is JetpackCompose?", + 1761847286L, + "", + null + ), + false, { + }, { + } + ) Spacer(modifier = Modifier.height(8.dp)) } From 4b7413bc2f6fdfdfde28d713895f8d3a108c796e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 9 Mar 2026 16:50:01 +0100 Subject: [PATCH 08/12] fix codacy Signed-off-by: alperozturk96 --- .../assistant/conversation/ConversationScreen.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt index dce86889026e..1e7f48088563 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt @@ -262,8 +262,10 @@ private fun ConversationListPreview() { "", null ), - false, { - }, { + false, + { + }, + { } ) @@ -278,8 +280,10 @@ private fun ConversationListPreview() { "", null ), - false, { - }, { + false, + { + }, + { } ) From bf198b3e2189d8155c2f196fd49cb38b938a0c45 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 9 Mar 2026 16:56:54 +0100 Subject: [PATCH 09/12] fix ui padding Signed-off-by: alperozturk96 --- .../com/nextcloud/client/assistant/AssistantScreen.kt | 2 -- .../assistant/conversation/ConversationScreen.kt | 11 ++++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index 233b5c25ec41..5feb120d98cd 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -220,8 +220,6 @@ fun AssistantScreen( color = MaterialTheme.colorScheme.onSurface, maxLines = 1 ) - - Spacer(modifier = Modifier.height(8.dp)) } } } diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt index 1e7f48088563..d1235c0a6ef5 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt @@ -168,7 +168,7 @@ private fun ConversationList( LazyColumn( modifier = modifier .fillMaxSize() - .padding(16.dp), + .padding(vertical = 16.dp), verticalArrangement = Arrangement.Bottom ) { items(conversations) { conversation -> @@ -218,7 +218,6 @@ private fun ConversationListItem( Box( modifier = Modifier .fillMaxWidth() - .height(52.dp) .background( if (isSelected) { MaterialTheme.colorScheme.surfaceVariant @@ -226,11 +225,11 @@ private fun ConversationListItem( MaterialTheme.colorScheme.surface } ) + .height(52.dp) .combinedClickable( onClick = onClick, onLongClick = onLongPressed - ) - .padding(horizontal = 4.dp), + ), contentAlignment = Alignment.CenterStart ) { Text( @@ -244,7 +243,9 @@ private fun ConversationListItem( colorResource(R.color.text_color) }, textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) ) } } From 7abe896f4560f0da8f4f0c081c0ccb778cf588e9 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 10 Mar 2026 14:51:11 +0100 Subject: [PATCH 10/12] fix send button activeness Signed-off-by: alperozturk96 --- .../client/assistant/AssistantScreen.kt | 4 +++- .../client/assistant/chat/ChatContent.kt | 3 ++- .../client/assistant/chat/ChatUIState.kt | 18 ++++++++++-------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index 5feb120d98cd..40e8ba2a850e 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -330,6 +330,7 @@ private fun InputBar( ) { val scope = rememberCoroutineScope() val text by viewModel.inputBarText.collectAsState() + val chatUIState by chatViewModel.uiState.collectAsState() Surface( tonalElevation = 3.dp, @@ -387,7 +388,8 @@ private fun InputBar( delay(CHAT_INPUT_DELAY) viewModel.updateInputBarText("") } - } + }, + enabled = chatUIState.canSend() ) { Icon( painter = painterResource(id = R.drawable.ic_send), diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt index 5468ff420de0..7c5f11dd205b 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape @@ -138,7 +139,7 @@ private fun ChatMessageList( messages: List, showTypingIndicator: Boolean, modifier: Modifier = Modifier, - listState: androidx.compose.foundation.lazy.LazyListState, + listState: LazyListState, bottomSlot: (@Composable () -> Unit)? = null ) { LazyColumn( diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt index e13efec37f8a..b2cefa8031f7 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt @@ -17,13 +17,15 @@ sealed class ChatUIState { data class Thinking(val messages: List) : ChatUIState() data class RetryAvailable(val messages: List) : ChatUIState() data class Error(val messages: List, val errorType: ChatErrorType) : ChatUIState() -} -fun ChatUIState.messages(): List = when (this) { - is ChatUIState.Content -> messages - is ChatUIState.Sending -> messages - is ChatUIState.Thinking -> messages - is ChatUIState.RetryAvailable -> messages - is ChatUIState.Error -> messages - is ChatUIState.Loading, is ChatUIState.Empty -> emptyList() + fun messages(): List = when (this) { + is Content -> messages + is Sending -> messages + is Thinking -> messages + is RetryAvailable -> messages + is Error -> messages + is Loading, is Empty -> emptyList() + } + + fun canSend(): Boolean = (this == Empty || this is Content) } From 69e21121eef8a0d01a5e12818b26c9abac8eb999 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 11 Mar 2026 08:38:11 +0100 Subject: [PATCH 11/12] fix lint and tint icon color correctly when cant send any more message Signed-off-by: alperozturk96 --- .../com/nextcloud/client/assistant/AssistantScreen.kt | 8 +++++++- app/src/main/res/values/strings.xml | 1 - 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index 40e8ba2a850e..e078be44bcb8 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -394,7 +394,13 @@ private fun InputBar( Icon( painter = painterResource(id = R.drawable.ic_send), contentDescription = stringResource(R.string.assistant_screen_send_message), - tint = MaterialTheme.colorScheme.primary + tint = if (chatUIState.canSend()) { + MaterialTheme.colorScheme.primary + } else { + colorResource( + R.color.disabled_text + ) + } ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8040eee2be8..e4f1d21778ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -66,7 +66,6 @@ Delete task Are you sure you want to delete this task? Delete Task - Choose an option Task created An error occurred while creating the task Task deleted From d95a894ee9f609041368a390c5541e12a9846934 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 11 Mar 2026 08:41:51 +0100 Subject: [PATCH 12/12] clear input bar text when switching conversation Signed-off-by: alperozturk96 --- .../main/java/com/nextcloud/client/assistant/AssistantScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index e078be44bcb8..2223fe2692e7 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -165,6 +165,7 @@ fun AssistantScreen( pagerState.scrollToPage(AssistantPage.Content.id) } }, openChat = { conversation -> + viewModel.updateInputBarText("") chatViewModel.updateSessionTitle(conversation.timestamp) chatViewModel.selectConversation(conversation.id) taskTypes.getChat()?.let { chatTaskType ->