From 92fb702650b2f21a1a55e8bae9d4689c114a058c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 13:48:26 +0100 Subject: [PATCH 01/13] 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 863a6346b687..fe4e39799275 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -9,7 +9,9 @@ package com.nextcloud.client.assistant import android.app.Activity import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -23,8 +25,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -55,6 +59,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nextcloud.client.assistant.chat.ChatContent +import com.nextcloud.client.assistant.chat.ChatViewModel import com.nextcloud.client.assistant.conversation.ConversationScreen import com.nextcloud.client.assistant.conversation.ConversationViewModel import com.nextcloud.client.assistant.conversation.repository.MockConversationRemoteRepository @@ -92,11 +97,13 @@ private const val PULL_TO_REFRESH_DELAY = 1500L fun AssistantScreen( composeViewModel: ComposeViewModel, viewModel: AssistantViewModel, + chatViewModel: ChatViewModel, conversationViewModel: ConversationViewModel, capability: OCCapability, activity: Activity ) { val selectedText by composeViewModel.selectedText.collectAsState() + val sessionTitle by chatViewModel.sessionTitle.collectAsState() val sessionId by viewModel.sessionId.collectAsState() val messageId by viewModel.snackbarMessageId.collectAsState() val screenOverlayState by viewModel.screenOverlayState.collectAsState() @@ -140,12 +147,8 @@ fun AssistantScreen( } } - LaunchedEffect(sessionId) { + LaunchedEffect(Unit) { viewModel.startPolling(sessionId) - - sessionId?.let { - viewModel.fetchChatMessages(it) - } } DisposableEffect(Unit) { @@ -165,7 +168,7 @@ fun AssistantScreen( pagerState.scrollToPage(AssistantPage.Content.id) } }, openChat = { newSessionId -> - viewModel.initSessionId(newSessionId) + chatViewModel.selectConversation(newSessionId) taskTypes.getChat()?.let { chatTaskType -> viewModel.selectTaskType(chatTaskType) } @@ -184,9 +187,9 @@ fun AssistantScreen( scope.launch { delay(PULL_TO_REFRESH_DELAY) - val newSessionId = sessionId - if (newSessionId != null) { - viewModel.fetchChatMessages(newSessionId) + val currentSessionId = sessionId + if (currentSessionId != null) { + chatViewModel.selectConversation(currentSessionId) } else { viewModel.fetchTaskList() } @@ -194,14 +197,35 @@ fun AssistantScreen( } ), topBar = { - taskTypes?.let { - TaskTypesRow(selectedTaskType, data = it, selectTaskType = { task -> - viewModel.selectTaskType(task) - }, navigateToConversationList = { - scope.launch { - pagerState.scrollToPage(AssistantPage.Conversation.id) + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + taskTypes?.let { + TaskTypesRow(selectedTaskType, data = it, selectTaskType = { task -> + viewModel.selectTaskType(task) + }, navigateToConversationList = { + scope.launch { + pagerState.scrollToPage(AssistantPage.Conversation.id) + } + }) + } + + if (selectedTaskType?.isChat() == true && sessionTitle != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = sessionTitle!!, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1 + ) + + Spacer(modifier = Modifier.height(8.dp)) } - }) + } } }, bottomBar = { @@ -209,7 +233,8 @@ fun AssistantScreen( InputBar( sessionId, selectedTaskType, - viewModel + viewModel, + chatViewModel ) } }, @@ -257,7 +282,7 @@ fun AssistantScreen( AssistantScreenState.ChatContent -> { ChatContent( - viewModel = viewModel, + chatViewModel = chatViewModel, modifier = Modifier.padding(paddingValues) ) } @@ -301,7 +326,12 @@ fun AssistantScreen( @Suppress("LongMethod") @Composable -private fun InputBar(sessionId: Long?, selectedTaskType: TaskTypeData?, viewModel: AssistantViewModel) { +private fun InputBar( + sessionId: Long?, + selectedTaskType: TaskTypeData?, + viewModel: AssistantViewModel, + chatViewModel: ChatViewModel +) { val scope = rememberCoroutineScope() val text by viewModel.inputBarText.collectAsState() @@ -349,9 +379,9 @@ private fun InputBar(sessionId: Long?, selectedTaskType: TaskTypeData?, viewMode val taskType = selectedTaskType ?: return@IconButton if (taskType.isChat()) { if (sessionId != null) { - viewModel.sendChatMessage(content = text, sessionId) + chatViewModel.sendMessage(content = text, sessionId = sessionId) } else { - viewModel.createConversation(text) + chatViewModel.startNewConversation(content = text) } } else { viewModel.createTask(input = text, taskType = taskType) @@ -498,6 +528,7 @@ private fun AssistantScreenPreview() { composeViewModel = ComposeViewModel(), conversationViewModel = getMockConversationViewModel(), viewModel = getMockAssistantViewModel(false), + chatViewModel = ChatViewModel(MockAssistantRemoteRepository()), activity = ComposeActivity(), capability = OCCapability().apply { versionMayor = 30 @@ -517,6 +548,7 @@ private fun AssistantEmptyScreenPreview() { composeViewModel = ComposeViewModel(), conversationViewModel = getMockConversationViewModel(), viewModel = getMockAssistantViewModel(true), + chatViewModel = ChatViewModel(MockAssistantRemoteRepository()), activity = ComposeActivity(), capability = OCCapability().apply { versionMayor = 30 diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt index 4742b0b76724..f26a0e2cdf11 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 d7ff418a0f02..c6ebd00aebc3 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 @@ -108,7 +111,6 @@ Hello there! What can I help you with today? Try sending a message to spark a conversation. - Recommended files Assistant From e709492b5e001f9827bd53b4f5a2785fd0500857 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 13:57:09 +0100 Subject: [PATCH 02/13] 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 70f66e2ae8076dab865bf5fa5a35f77bd3b7f51f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 14:14:52 +0100 Subject: [PATCH 03/13] 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 8ee48e444d4320a028c59554710b255821ed5e28 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 14:38:02 +0100 Subject: [PATCH 04/13] fixes Signed-off-by: alperozturk96 --- .../com/nextcloud/client/assistant/chat/ChatViewModel.kt | 8 +++++++- .../client/assistant/conversation/ConversationScreen.kt | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt index bb2b80fa1d28..f7765a7bb1df 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt @@ -63,7 +63,13 @@ class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : V when { currentChatTaskId != null && currentChatTaskId != "0" -> startPolling(sessionId) lastMessageIsHuman -> _uiState.update { ChatUIState.RetryAvailable(currentMessages) } - else -> _uiState.update { ChatUIState.Content(currentMessages) } + else -> _uiState.update { + if (currentMessages.isEmpty()) { + ChatUIState.Empty + } else { + ChatUIState.Content(currentMessages) + } + } } } } diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt index 29e37fabade3..677813929e16 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt @@ -108,6 +108,7 @@ fun ConversationScreen(viewModel: ConversationViewModel, close: () -> Unit, open floatingActionButton = { FloatingActionButton(onClick = { viewModel.createConversation(null, onResult = { + viewModel.selectConversation(null) openChat(it) }) }) { From d7926700ae6aafe3732c46488eb16cefaaac5dc6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 3 Mar 2026 14:53:56 +0100 Subject: [PATCH 05/13] fixes Signed-off-by: alperozturk96 --- .../client/assistant/AssistantScreen.kt | 8 +++---- .../client/assistant/chat/ChatContent.kt | 4 ++-- .../client/assistant/chat/ChatViewModel.kt | 23 +++++++++++++++++-- .../conversation/ConversationViewModel.kt | 4 ++-- .../nextcloud/utils/date/DateFormatPattern.kt | 7 +++++- 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index fe4e39799275..233b5c25ec41 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -11,7 +11,6 @@ import android.app.Activity import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -25,10 +24,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -167,8 +164,9 @@ fun AssistantScreen( scope.launch { pagerState.scrollToPage(AssistantPage.Content.id) } - }, openChat = { newSessionId -> - chatViewModel.selectConversation(newSessionId) + }, openChat = { conversation -> + chatViewModel.updateSessionTitle(conversation.timestamp) + chatViewModel.selectConversation(conversation.id) taskTypes.getChat()?.let { chatTaskType -> viewModel.selectTaskType(chatTaskType) } diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt index c8ef16b36a2b..5468ff420de0 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt @@ -201,9 +201,9 @@ private fun ChatEmptyContent(modifier: Modifier = Modifier) { Text( text = stringResource(R.string.assistant_screen_empty_content_title), - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, - color = colorResource(R.color.secondary_text_color) + color = colorResource(R.color.text_color) ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt index f7765a7bb1df..017a434602af 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatViewModel.kt @@ -11,6 +11,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository import com.nextcloud.utils.TimeConstants.MILLIS_PER_SECOND +import com.nextcloud.utils.date.DateFormatPattern +import com.nextcloud.utils.date.DateFormatter import com.nextcloud.utils.extensions.isHuman import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest @@ -56,13 +58,18 @@ class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : V val lastMessageIsHuman = currentMessages.lastOrNull()?.isHuman() == true - _sessionTitle.update { - session?.sessionTitle + // update session title if exists + session?.sessionTitle?.let { + _sessionTitle.update { + it + } } when { currentChatTaskId != null && currentChatTaskId != "0" -> startPolling(sessionId) + lastMessageIsHuman -> _uiState.update { ChatUIState.RetryAvailable(currentMessages) } + else -> _uiState.update { if (currentMessages.isEmpty()) { ChatUIState.Empty @@ -181,6 +188,18 @@ class ChatViewModel(private val remoteRepository: AssistantRemoteRepository) : V } } + fun updateSessionTitle(timestamp: Long) { + val newSessionTitle = DateFormatter + .timestampToDateRepresentation( + timestamp, + DateFormatPattern.MonthDayYearTime + ) + + _sessionTitle.update { + newSessionTitle + } + } + override fun onCleared() { super.onCleared() stopPolling() diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt index 062d07c55804..a887f31f5ab5 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt @@ -72,7 +72,7 @@ class ConversationViewModel(private val remoteRepository: ConversationRemoteRepo } } - fun createConversation(title: String?, onResult: (Long) -> Unit) { + fun createConversation(title: String?, onResult: (Conversation) -> Unit) { viewModelScope.launch(Dispatchers.IO) { val timestamp = System.currentTimeMillis().div(MILLIS_PER_SECOND) val newConversation = remoteRepository.createConversation(title, timestamp) @@ -80,7 +80,7 @@ class ConversationViewModel(private val remoteRepository: ConversationRemoteRepo _conversations.update { listOf(newConversation.session) + it } - onResult(newConversation.session.id) + onResult(newConversation.session) } else { _errorMessageId.update { R.string.conversation_screen_create_error_title diff --git a/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt b/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt index a3852d67af25..7324596e3a59 100644 --- a/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt +++ b/app/src/main/java/com/nextcloud/utils/date/DateFormatPattern.kt @@ -16,5 +16,10 @@ enum class DateFormatPattern(val pattern: String) { /** * Aug 3 */ - MonthWithDate("MMM d") + MonthWithDate("MMM d"), + + /** + * March 03, 2026 14:38 + */ + MonthDayYearTime("MMMM dd, yyyy HH:mm") } From a8e3701299db0b8166c1bdb13158167d55bfd257 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 5 Mar 2026 09:12:52 +0100 Subject: [PATCH 06/13] 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 c6ebd00aebc3..e9829f86a02e 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 55dbd9885c7a1a477e71e62c7ade3db045a6aeae Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 9 Mar 2026 16:49:45 +0100 Subject: [PATCH 07/13] rebase Signed-off-by: alperozturk96 --- .../conversation/ConversationScreen.kt | 103 ++++++++++++------ 1 file changed, 69 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt index 677813929e16..dce86889026e 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt @@ -9,6 +9,7 @@ package com.nextcloud.client.assistant.conversation +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,13 +36,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -52,7 +52,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.nextcloud.client.assistant.conversation.model.ConversationScreenState import com.nextcloud.ui.composeComponents.bottomSheet.MoreActionsBottomSheet import com.owncloud.android.R @@ -61,7 +60,7 @@ import com.owncloud.android.lib.resources.assistant.chat.model.Conversation @OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod") @Composable -fun ConversationScreen(viewModel: ConversationViewModel, close: () -> Unit, openChat: (Long) -> Unit) { +fun ConversationScreen(viewModel: ConversationViewModel, close: () -> Unit, openChat: (Conversation) -> Unit) { val screenState by viewModel.screenState.collectAsState() val errorMessageId by viewModel.errorMessageId.collectAsState() val conversations by viewModel.conversations.collectAsState() @@ -161,9 +160,10 @@ private fun ConversationList( viewModel: ConversationViewModel, conversations: List, modifier: Modifier = Modifier, - openChat: (Long) -> Unit + openChat: (Conversation) -> Unit ) { - var selectedConversationId by remember { mutableLongStateOf(-1L) } + var showConversationActions by remember { mutableStateOf(false) } + val selectedConversationId by viewModel.selectedConversationId.collectAsState() LazyColumn( modifier = modifier @@ -174,18 +174,20 @@ private fun ConversationList( items(conversations) { conversation -> ConversationListItem( conversation = conversation, + isSelected = (conversation.id == selectedConversationId), onClick = { - openChat(conversation.id) + viewModel.selectConversation(conversation.id) + openChat(conversation) }, onLongPressed = { - selectedConversationId = conversation.id + showConversationActions = true } ) Spacer(modifier = Modifier.height(4.dp)) } } - if (selectedConversationId != -1L) { + if (showConversationActions) { val currentId = selectedConversationId val bottomSheetAction = listOf( @@ -195,42 +197,55 @@ private fun ConversationList( ) { val sessionId: String = currentId.toString() viewModel.deleteConversation(sessionId) - selectedConversationId = -1L + showConversationActions = false } ) MoreActionsBottomSheet( actions = bottomSheetAction, - onDismiss = { selectedConversationId = -1L } + onDismiss = { showConversationActions = false } ) } } @Composable -private fun ConversationListItem(conversation: Conversation, onClick: () -> Unit, onLongPressed: () -> Unit) { - Surface( +private fun ConversationListItem( + conversation: Conversation, + isSelected: Boolean, + onClick: () -> Unit, + onLongPressed: () -> Unit +) { + Box( modifier = Modifier .fillMaxWidth() + .height(52.dp) + .background( + if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surface + } + ) .combinedClickable( onClick = onClick, onLongClick = onLongPressed ) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = conversation.titleRepresentation(), - style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = colorResource(R.color.text_color), - modifier = Modifier.weight(1f) - ) - } + Text( + text = conversation.titleRepresentation(), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) { + MaterialTheme.colorScheme.onSurface + } else { + colorResource(R.color.text_color) + }, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) } } @@ -238,15 +253,35 @@ private fun ConversationListItem(conversation: Conversation, onClick: () -> Unit @Composable private fun ConversationListPreview() { Column { - ConversationListItem(Conversation(1L, "User1", "Who is Al Pacino?", 1762847286L, "", null), { - }, { - }) + ConversationListItem( + Conversation( + 1L, + "User1", + "Who is Al Pacino?", + 1762847286L, + "", + null + ), + false, { + }, { + } + ) Spacer(modifier = Modifier.height(8.dp)) - ConversationListItem(Conversation(2L, "User1", "What is JetpackCompose?", 1761847286L, "", null), { - }, { - }) + ConversationListItem( + Conversation( + 2L, + "User1", + "What is JetpackCompose?", + 1761847286L, + "", + null + ), + false, { + }, { + } + ) Spacer(modifier = Modifier.height(8.dp)) } From 8141d3e37f5c300e69f71fd29facc6c8e8dd83d5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 9 Mar 2026 16:50:01 +0100 Subject: [PATCH 08/13] fix codacy Signed-off-by: alperozturk96 --- .../assistant/conversation/ConversationScreen.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt index dce86889026e..1e7f48088563 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt @@ -262,8 +262,10 @@ private fun ConversationListPreview() { "", null ), - false, { - }, { + false, + { + }, + { } ) @@ -278,8 +280,10 @@ private fun ConversationListPreview() { "", null ), - false, { - }, { + false, + { + }, + { } ) From cfa1be986ccad1cf87a9f11ee023a6effdca4a66 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 9 Mar 2026 16:56:54 +0100 Subject: [PATCH 09/13] fix ui padding Signed-off-by: alperozturk96 --- .../com/nextcloud/client/assistant/AssistantScreen.kt | 2 -- .../assistant/conversation/ConversationScreen.kt | 11 ++++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index 233b5c25ec41..5feb120d98cd 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -220,8 +220,6 @@ fun AssistantScreen( color = MaterialTheme.colorScheme.onSurface, maxLines = 1 ) - - Spacer(modifier = Modifier.height(8.dp)) } } } diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt index 1e7f48088563..d1235c0a6ef5 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt @@ -168,7 +168,7 @@ private fun ConversationList( LazyColumn( modifier = modifier .fillMaxSize() - .padding(16.dp), + .padding(vertical = 16.dp), verticalArrangement = Arrangement.Bottom ) { items(conversations) { conversation -> @@ -218,7 +218,6 @@ private fun ConversationListItem( Box( modifier = Modifier .fillMaxWidth() - .height(52.dp) .background( if (isSelected) { MaterialTheme.colorScheme.surfaceVariant @@ -226,11 +225,11 @@ private fun ConversationListItem( MaterialTheme.colorScheme.surface } ) + .height(52.dp) .combinedClickable( onClick = onClick, onLongClick = onLongPressed - ) - .padding(horizontal = 4.dp), + ), contentAlignment = Alignment.CenterStart ) { Text( @@ -244,7 +243,9 @@ private fun ConversationListItem( colorResource(R.color.text_color) }, textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) ) } } From def591fbf27feae2c6e34301b8706cfa7b09d3a4 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 10 Mar 2026 14:51:11 +0100 Subject: [PATCH 10/13] fix send button activeness Signed-off-by: alperozturk96 --- .../client/assistant/AssistantScreen.kt | 4 +++- .../client/assistant/chat/ChatContent.kt | 3 ++- .../client/assistant/chat/ChatUIState.kt | 18 ++++++++++-------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index 5feb120d98cd..40e8ba2a850e 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -330,6 +330,7 @@ private fun InputBar( ) { val scope = rememberCoroutineScope() val text by viewModel.inputBarText.collectAsState() + val chatUIState by chatViewModel.uiState.collectAsState() Surface( tonalElevation = 3.dp, @@ -387,7 +388,8 @@ private fun InputBar( delay(CHAT_INPUT_DELAY) viewModel.updateInputBarText("") } - } + }, + enabled = chatUIState.canSend() ) { Icon( painter = painterResource(id = R.drawable.ic_send), diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt index 5468ff420de0..7c5f11dd205b 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape @@ -138,7 +139,7 @@ private fun ChatMessageList( messages: List, showTypingIndicator: Boolean, modifier: Modifier = Modifier, - listState: androidx.compose.foundation.lazy.LazyListState, + listState: LazyListState, bottomSlot: (@Composable () -> Unit)? = null ) { LazyColumn( diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt index e13efec37f8a..b2cefa8031f7 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatUIState.kt @@ -17,13 +17,15 @@ sealed class ChatUIState { data class Thinking(val messages: List) : ChatUIState() data class RetryAvailable(val messages: List) : ChatUIState() data class Error(val messages: List, val errorType: ChatErrorType) : ChatUIState() -} -fun ChatUIState.messages(): List = when (this) { - is ChatUIState.Content -> messages - is ChatUIState.Sending -> messages - is ChatUIState.Thinking -> messages - is ChatUIState.RetryAvailable -> messages - is ChatUIState.Error -> messages - is ChatUIState.Loading, is ChatUIState.Empty -> emptyList() + fun messages(): List = when (this) { + is Content -> messages + is Sending -> messages + is Thinking -> messages + is RetryAvailable -> messages + is Error -> messages + is Loading, is Empty -> emptyList() + } + + fun canSend(): Boolean = (this == Empty || this is Content) } From 76b59a73faca1af2ee8c229f9ec35050afca9ef0 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 11 Mar 2026 08:38:11 +0100 Subject: [PATCH 11/13] fix lint and tint icon color correctly when cant send any more message Signed-off-by: alperozturk96 --- .../com/nextcloud/client/assistant/AssistantScreen.kt | 8 +++++++- app/src/main/res/values/strings.xml | 1 - 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index 40e8ba2a850e..e078be44bcb8 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -394,7 +394,13 @@ private fun InputBar( Icon( painter = painterResource(id = R.drawable.ic_send), contentDescription = stringResource(R.string.assistant_screen_send_message), - tint = MaterialTheme.colorScheme.primary + tint = if (chatUIState.canSend()) { + MaterialTheme.colorScheme.primary + } else { + colorResource( + R.color.disabled_text + ) + } ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e9829f86a02e..7d496b2fc40f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -66,7 +66,6 @@ Delete task Are you sure you want to delete this task? Delete Task - Choose an option Task created An error occurred while creating the task Task deleted From 748dff8f30a8048bbc3099719b7ec895612b7baf Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 11 Mar 2026 08:41:51 +0100 Subject: [PATCH 12/13] 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 e078be44bcb8..2223fe2692e7 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -165,6 +165,7 @@ fun AssistantScreen( pagerState.scrollToPage(AssistantPage.Content.id) } }, openChat = { conversation -> + viewModel.updateInputBarText("") chatViewModel.updateSessionTitle(conversation.timestamp) chatViewModel.selectConversation(conversation.id) taskTypes.getChat()?.let { chatTaskType -> From 5cebaef3f0b7c9357d528596b5c8c6186868683b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 11 Mar 2026 08:53:35 +0100 Subject: [PATCH 13/13] fix conflict Signed-off-by: alperozturk96 --- .../com/nextcloud/client/assistant/AssistantViewModel.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 f26a0e2cdf11..989bd26a1c97 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt @@ -134,10 +134,13 @@ class AssistantViewModel( when { selectedTaskType == null -> AssistantScreenState.Loading + 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) { AssistantScreenState.TaskContent