From 47c4b1e4fef4f756c21c51c5647ff6287332a844 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 13:48:26 +0100 Subject: [PATCH 1/7] fix(assistant): chat task flow Signed-off-by: alperozturk96 --- .../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/ConversationScreen.kt | 34 ++- .../conversation/ConversationViewModel.kt | 9 + .../ui/composeActivity/ComposeActivity.kt | 5 +- app/src/main/res/values/strings.xml | 4 +- 10 files changed, 511 insertions(+), 211 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 14a3a4b70c71..b7c74c1c7a8c 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 @@ -89,11 +94,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() @@ -130,12 +137,8 @@ fun AssistantScreen( } } - LaunchedEffect(sessionId) { + LaunchedEffect(Unit) { viewModel.startPolling(sessionId) - - sessionId?.let { - viewModel.fetchChatMessages(it) - } } DisposableEffect(Unit) { @@ -155,7 +158,7 @@ fun AssistantScreen( pagerState.scrollToPage(AssistantPage.Content.id) } }, openChat = { newSessionId -> - viewModel.initSessionId(newSessionId) + chatViewModel.selectConversation(newSessionId) taskTypes.getChat()?.let { chatTaskType -> viewModel.selectTaskType(chatTaskType) } @@ -174,9 +177,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() } @@ -184,14 +187,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 = { @@ -199,7 +223,8 @@ fun AssistantScreen( InputBar( sessionId, selectedTaskType, - viewModel + viewModel, + chatViewModel ) } }, @@ -247,7 +272,7 @@ fun AssistantScreen( AssistantScreenState.ChatContent -> { ChatContent( - viewModel = viewModel, + chatViewModel = chatViewModel, modifier = Modifier.padding(paddingValues) ) } @@ -291,7 +316,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() @@ -339,9 +369,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) @@ -474,6 +504,7 @@ private fun AssistantScreenPreview() { composeViewModel = ComposeViewModel(), conversationViewModel = getMockConversationViewModel(), viewModel = getMockAssistantViewModel(false), + chatViewModel = ChatViewModel(MockAssistantRemoteRepository()), activity = ComposeActivity(), capability = OCCapability().apply { versionMayor = 30 @@ -493,6 +524,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 7bc212667745..df16da8243fb 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/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt index 36ae8c1ee8f6..0d5080dbc93f 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 @@ -41,7 +41,7 @@ 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 @@ -162,7 +162,8 @@ private fun ConversationList( modifier: Modifier = Modifier, openChat: (Long) -> Unit ) { - var selectedConversationId by remember { mutableLongStateOf(-1L) } + var showConversationActions by remember { mutableStateOf(false) } + val selectedConversationId by viewModel.selectedConversationId.collectAsState() LazyColumn( modifier = modifier @@ -173,18 +174,20 @@ private fun ConversationList( items(conversations) { conversation -> ConversationListItem( conversation = conversation, + isSelected = (conversation.id == selectedConversationId), onClick = { + viewModel.selectConversation(conversation.id) openChat(conversation.id) }, onLongPressed = { - selectedConversationId = conversation.id + showConversationActions = true } ) Spacer(modifier = Modifier.height(4.dp)) } } - if (selectedConversationId != -1L) { + if (showConversationActions) { val currentId = selectedConversationId val bottomSheetAction = listOf( @@ -194,26 +197,37 @@ private fun ConversationList( ) { val sessionId: String = currentId.toString() viewModel.deleteConversation(sessionId) - selectedConversationId = -1L + showConversationActions = false } ) MoreActionsBottomSheet( actions = bottomSheetAction, - dismiss = { selectedConversationId = -1L } + dismiss = { showConversationActions = false } ) } } @Composable -private fun ConversationListItem(conversation: Conversation, onClick: () -> Unit, onLongPressed: () -> Unit) { +private fun ConversationListItem( + conversation: Conversation, + isSelected: Boolean, + onClick: () -> Unit, + onLongPressed: () -> Unit +) { Surface( modifier = Modifier .fillMaxWidth() .combinedClickable( onClick = onClick, onLongClick = onLongPressed - ) + ), + color = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surface + }, + tonalElevation = if (isSelected) 2.dp else 0.dp ) { Row( modifier = Modifier @@ -237,13 +251,13 @@ 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, { }, { }) 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 3912bf80aca3..ee85b186be4e 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 @@ -106,7 +109,6 @@ Hello there! What can I help you with today? Try sending a message to spark a conversation. - Recommended files Assistant From 78ec81bd574d5ccaca3ab439e3bcc1f03f37f1d5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 13:57:09 +0100 Subject: [PATCH 2/7] 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 ba07540de61d26b2e8a0ce0d93e14f4890ba17b8 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 14:14:52 +0100 Subject: [PATCH 3/7] 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 1f5033aeef2b19f7c3c829ebd2932594972f3c5f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 14:32:01 +0100 Subject: [PATCH 4/7] increase tap size of conversation list item Signed-off-by: alperozturk96 --- .../conversation/ConversationScreen.kt | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 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 0d5080dbc93f..6ae8e65acb27 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,7 +36,6 @@ 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 @@ -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 @@ -215,35 +214,35 @@ private fun ConversationListItem( onClick: () -> Unit, onLongPressed: () -> Unit ) { - Surface( + Box( modifier = Modifier .fillMaxWidth() + .height(52.dp) + .background( + if (isSelected) + MaterialTheme.colorScheme.surfaceVariant + else + MaterialTheme.colorScheme.surface + ) .combinedClickable( onClick = onClick, onLongClick = onLongPressed - ), - color = if (isSelected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - } else { - MaterialTheme.colorScheme.surface - }, - tonalElevation = if (isSelected) 2.dp else 0.dp - ) { - 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) ) - } + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart + ) { + 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() + ) } } From ac130e1f47ee214ec9ccc455c86f1c8d4301f613 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 14:38:02 +0100 Subject: [PATCH 5/7] 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 6ae8e65acb27..aea8f3826c4c 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 @@ -107,6 +107,7 @@ fun ConversationScreen(viewModel: ConversationViewModel, close: () -> Unit, open floatingActionButton = { FloatingActionButton(onClick = { viewModel.createConversation(null, onResult = { + viewModel.selectConversation(null) openChat(it) }) }) { From 836df2983600c4b62300b0d0df27b96cc4025a8d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 14:53:56 +0100 Subject: [PATCH 6/7] fixes Signed-off-by: alperozturk96 --- .../client/assistant/AssistantScreen.kt | 8 +++---- .../client/assistant/chat/ChatContent.kt | 4 ++-- .../client/assistant/chat/ChatViewModel.kt | 23 +++++++++++++++++-- .../conversation/ConversationScreen.kt | 18 ++++++++------- .../conversation/ConversationViewModel.kt | 4 ++-- .../nextcloud/utils/date/DateFormatPattern.kt | 7 +++++- 6 files changed, 44 insertions(+), 20 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 b7c74c1c7a8c..827a710f3917 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 @@ -157,8 +154,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/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt index aea8f3826c4c..f71e9c2b93c5 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 @@ -60,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() @@ -160,7 +160,7 @@ private fun ConversationList( viewModel: ConversationViewModel, conversations: List, modifier: Modifier = Modifier, - openChat: (Long) -> Unit + openChat: (Conversation) -> Unit ) { var showConversationActions by remember { mutableStateOf(false) } val selectedConversationId by viewModel.selectedConversationId.collectAsState() @@ -177,7 +177,7 @@ private fun ConversationList( isSelected = (conversation.id == selectedConversationId), onClick = { viewModel.selectConversation(conversation.id) - openChat(conversation.id) + openChat(conversation) }, onLongPressed = { showConversationActions = true @@ -220,10 +220,11 @@ private fun ConversationListItem( .fillMaxWidth() .height(52.dp) .background( - if (isSelected) + if (isSelected) { MaterialTheme.colorScheme.surfaceVariant - else + } else { MaterialTheme.colorScheme.surface + } ) .combinedClickable( onClick = onClick, @@ -237,10 +238,11 @@ private fun ConversationListItem( style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (isSelected) + color = if (isSelected) { MaterialTheme.colorScheme.onSurface - else - colorResource(R.color.text_color), + } else { + colorResource(R.color.text_color) + }, textAlign = TextAlign.Start, modifier = Modifier.fillMaxWidth() ) 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 da60794bf864fe9e7ca66d219b64b12421142080 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 5 Mar 2026 09:12:52 +0100 Subject: [PATCH 7/7] 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 ee85b186be4e..73c1badd15be 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