From 7515de840f8ee50e01400c20f477834edbe6b502 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 | 101 +------ .../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, 486 insertions(+), 198 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 def6bf60882b..b7496959a23b 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 @@ -198,48 +152,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) @@ -268,15 +180,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 c35f8fa6a506..e30b05af1b88 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 @@ -128,15 +129,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 b9f516065048..f3076b65ef50 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 76f4e86e079ec503604f18ae7bb5cc8a947adec6 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 acc828f38d9b35ba7d6044aeb88c813ff1fd3c49 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 64d265bc004696928a5040e08ef73519b5f2887e 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 36ae8c1ee8f6..6291241c31c9 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 36b1d8ccafbaa6c626c5c1a97efa2a3f6deea93a 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 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/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 9086f3d697fd2ce2eda39f3b21e93f66849df5d8 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 f3076b65ef50..7d496b2fc40f 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 aabd095d6a6b2114be3c2ca4077bb5513d20e613 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 | 101 ++++++++++++------ 1 file changed, 68 insertions(+), 33 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 6291241c31c9..6ba48602c8de 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,7 +197,7 @@ private fun ConversationList( ) { val sessionId: String = currentId.toString() viewModel.deleteConversation(sessionId) - selectedConversationId = -1L + showConversationActions = false } ) @@ -207,30 +209,43 @@ private fun ConversationList( } @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 5e40effb6b368455f539c8abba013047fe9b1f47 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 6ba48602c8de..fcb337076769 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 aaa636286e1ad59947ff5dc2fb808391baa35494 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 827a710f3917..171ca96f9bd0 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -210,8 +210,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 fcb337076769..93ab77c02782 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 edf02460c0620786eafbadbca7f578cc881a20f1 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 171ca96f9bd0..475ebfaa7445 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -320,6 +320,7 @@ private fun InputBar( ) { val scope = rememberCoroutineScope() val text by viewModel.inputBarText.collectAsState() + val chatUIState by chatViewModel.uiState.collectAsState() Surface( tonalElevation = 3.dp, @@ -377,7 +378,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 723358dc4a1c26f14c30af426ac8b3725af29bb7 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 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 475ebfaa7445..c7b4e8b6fcbe 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -384,7 +384,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 + ) + } ) } } From 8d6c454cfd19417c9d012acbc6872b65633c0815 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 [skip ci] --- .../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 c7b4e8b6fcbe..33d54fe301fd 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -155,6 +155,7 @@ fun AssistantScreen( pagerState.scrollToPage(AssistantPage.Content.id) } }, openChat = { conversation -> + viewModel.updateInputBarText("") chatViewModel.updateSessionTitle(conversation.timestamp) chatViewModel.selectConversation(conversation.id) taskTypes.getChat()?.let { chatTaskType ->