Conversation
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
…ant-improvements
…ant-improvements
…ant-improvements Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
…ant-improvements
…ant-improvements
There was a problem hiding this comment.
Pull request overview
This PR introduces an “Assistant Chat” experience to the iOS client, adding chat conversations/session handling alongside the existing Assistant task-based UI.
Changes:
- Refactors
NCAssistantto use new Observation-based models and conditionally render a Chat UI when the chat task type is selected. - Adds chat components (chat model/view, conversations view/model, input field) and wires them into the Assistant entry point in the main navigation controller.
- Removes the Assistant tile from “More app suggestions” UI and partially updates the Assistant UI tests to the renamed toolbar button identifier.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| iOSClient/More/NCMore.swift | Removes handling for the “openAssistant” menu action in selection logic. |
| iOSClient/More/Cells/NCMoreAppSuggestionsCell.xib | Removes the Assistant suggestion tile and its outlet wiring. |
| iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift | Removes Assistant outlet/gesture and related capability-based visibility. |
| iOSClient/Main/NCMainNavigationController.swift | Updates Assistant button to present NCAssistant with the new model dependencies. |
| iOSClient/Assistant/NCAssistantModel.swift | Migrates to @Observable, adds chat-type selection behavior, and converts several calls to async/await. |
| iOSClient/Assistant/NCAssistant.swift | Renders chat vs task UI, adds conversation picker entry point, and injects models via environment. |
| iOSClient/Assistant/Components/NCAssistantEmptyView.swift | Updates to new @Environment(NCAssistantModel.self) access pattern. |
| iOSClient/Assistant/Components/ChatInputField.swift | Adds a reusable chat/task input control. |
| iOSClient/Assistant/Chat/NCAssistantChatModel.swift | Implements chat messaging + polling model. |
| iOSClient/Assistant/Chat/NCAssistantChat.swift | Implements chat UI (message list, retry button, input field). |
| iOSClient/Account/NCAccount.swift | Improves unauthorized error banner by including the account in the localized message format. |
| Tests/NextcloudUITests/AssistantUITests.swift | Renames the toolbar button identifier used by tests (but flow is now outdated). |
| Nextcloud.xcodeproj/project.pbxproj | Adds new Assistant chat-related sources to the project. |
Comments suppressed due to low confidence (8)
iOSClient/Assistant/NCAssistantModel.swift:106
- In the non-V2 branch of
scheduleTask,guard let task, let taskV2 = ... else { return }can return without callinghandle(...), leavingisLoadingstuck. Make sureisLoadingis cleared and an error is reported when the server returns no task / conversion fails.
iOSClient/Assistant/NCAssistantModel.swift:170 loadAllTypesnon-V2 path returns early onguard let types else { return }without clearingisLoading. If the API returnstypes == nil(even alongside an error), the UI can remain stuck loading. Route all exit paths through thehandle(...)function (or clearisLoadingbefore returning).
Tests/NextcloudUITests/AssistantUITests.swift:59createTasknow tapsConversationsButton, but the subsequent steps still assume the “create assistant task” flow (InputTextEditor, “New Free text to text prompt task”, etc.). With the UI now using conversations/chat input, these UI tests will fail. Update the test flow to match the new UI entry point for creating tasks/messages (or keep the old Create button if tasks are still meant to be created via that screen).
private func createTask(input: String) {
app.navigationBars["Assistant"].buttons["ConversationsButton"].tap()
let inputTextEditor = app.textViews["InputTextEditor"]
inputTextEditor.await()
inputTextEditor.typeText(input)
app.navigationBars["New Free text to text prompt task"].buttons["Create"].tap()
}
iOSClient/Assistant/NCAssistantModel.swift:78
- In the non-V2 branch of
selectTask,guard let task else { return }can exit without callinghandle(...), which meansisLoadingstaystrueand errors aren’t surfaced. Ensure the nil-task path still clears loading state (and ideally sets an error flag/message).
iOSClient/Assistant/NCAssistantModel.swift:202 - In
loadAllTasksV2 path,guard let tasks = result.tasks?.tasks.filter(...) else { return }can exit without resettingisLoading/isRefreshing, leaving the UI stuck. Ensure the nil-tasks path still clears loading/refreshing state and surfaces an error when appropriate.
iOSClient/More/NCMore.swift:416 NCMore.loadItems()still adds a menu item withitem.url == "openAssistant", but the corresponding branch was removed fromdidSelectRowAt. As a result, tapping Assistant from the More tab won’t do anything (it falls through toapplicationHandle.didSelectItem, which is a no-op by default). Either re-add theopenAssistantpresentation logic here, or remove theopenAssistantitem from the menu so the UI doesn’t expose a dead entry.
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let item = sections[indexPath.section].items[indexPath.row]
// Action
if item.url.contains("segue") && !item.url.contains("//") {
self.navigationController?.performSegue(withIdentifier: item.url, sender: self)
} else if item.url.contains("openStoryboard") && !item.url.contains("//") {
let nameStoryboard = item.url.replacingOccurrences(of: "openStoryboard", with: "")
let storyboard = UIStoryboard(name: nameStoryboard, bundle: nil)
if let controller = storyboard.instantiateInitialViewController() {
if let vc = controller.topMostViewController() as? NCScan {
vc.controller = self.controller
}
controller.modalPresentationStyle = UIModalPresentationStyle.pageSheet
present(controller, animated: true, completion: nil)
}
} else if item.url.contains("//") {
if let browserWebVC = UIStoryboard(name: "NCBrowserWeb", bundle: nil).instantiateInitialViewController() as? NCBrowserWeb {
browserWebVC.urlBase = item.url
browserWebVC.isHiddenButtonExit = true
browserWebVC.titleBrowser = item.name
self.navigationController?.pushViewController(browserWebVC, animated: true)
self.navigationController?.navigationBar.isHidden = false
}
} else if item.url == "logout" {
let alertController = UIAlertController(title: "", message: NSLocalizedString("_want_delete_", comment: ""), preferredStyle: .alert)
let actionYes = UIAlertAction(title: NSLocalizedString("_yes_delete_", comment: ""), style: .default) { (_: UIAlertAction) in
if NCBrandOptions.shared.disable_intro {
if let viewController = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLogin") as? NCLogin {
viewController.controller = self.controller
let navigationController = UINavigationController(rootViewController: viewController)
navigationController.modalPresentationStyle = .fullScreen
self.present(navigationController, animated: true)
}
} else {
if let navigationController = UIStoryboard(name: "NCIntro", bundle: nil).instantiateInitialViewController() as? UINavigationController {
if let viewController = navigationController.topViewController as? NCIntroViewController {
viewController.controller = self.controller
}
navigationController.modalPresentationStyle = .fullScreen
self.present(navigationController, animated: true)
}
}
}
let actionNo = UIAlertAction(title: NSLocalizedString("_no_delete_", comment: ""), style: .default) { (_: UIAlertAction) in
print("You've pressed No button")
}
alertController.addAction(actionYes)
alertController.addAction(actionNo)
self.present(alertController, animated: true, completion: nil)
} else if item.url == "openSettings" {
let settingsView = NCSettingsView(model: NCSettingsModel(controller: self.controller))
let settingsController = UIHostingController(rootView: settingsView)
settingsController.title = NSLocalizedString("_settings_", comment: "")
navigationController?.pushViewController(settingsController, animated: true)
} else {
applicationHandle.didSelectItem(item, viewController: self)
iOSClient/Assistant/NCAssistantModel.swift:101
- In
scheduleTask,isLoadingis set totruebefore theTask {}. WhenuseV2is true andselectedTypeis nil, theguard let selectedType else { return }exits without ever resettingisLoading, leaving the UI stuck in loading state. GuardselectedTypebefore settingisLoading, or ensure the early-return path setsisLoading = false.
iOSClient/Assistant/NCAssistantModel.swift:246 loadDummyData()sets the “Chat” type id to "1", but the runtime chat detection useschatTypeId = "core:text2text:chat". This makes previews/tests of the chat UI behave incorrectly (chat will never be treated as selected). Consider usingchatTypeIdfor the dummy Chat type id soisSelectedTypeChatmatches.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Task { | ||
| let result = await NextcloudKit.shared.createAssistantChatMessage(messageRequest: request, account: ncSession.account) | ||
| if result.error == .success { | ||
| guard let chatMessage = result.chatMessage else { return } | ||
| messages.append(chatMessage) | ||
|
|
||
| stopPolling() | ||
| await generateChatSession() | ||
| startPollingForResponse() | ||
| } else { | ||
| //TODO | ||
| } | ||
|
|
||
| isSending = false | ||
| } |
There was a problem hiding this comment.
sendMessage does not handle the error case yet (//TODO). As-is, failures won’t re-enable sending or report an error. At minimum, set hasError = true, reset isSending/isSendingDisabled, and consider exposing an error message for the UI to present.
| guard let conversation = await sessionsModel.createNewConversation(title: input) else { return } | ||
| await selectConversation(selectedConversation: conversation) | ||
| sendMessage(input: input) |
There was a problem hiding this comment.
startNewConversationViaMessage sets isSending = true but returns early if createNewConversation fails, leaving isSending stuck and the input disabled/spinner visible. Ensure all early-return/error paths reset isSending / isSendingDisabled and surface an error to the user.
| guard let conversation = await sessionsModel.createNewConversation(title: input) else { return } | |
| await selectConversation(selectedConversation: conversation) | |
| sendMessage(input: input) | |
| isSendingDisabled = true | |
| if let conversation = await sessionsModel.createNewConversation(title: input) { | |
| await selectConversation(selectedConversation: conversation) | |
| sendMessage(input: input) | |
| } else { | |
| // Failed to create a new conversation; reset sending state and surface error | |
| hasError = true | |
| isSending = false | |
| isSendingDisabled = false | |
| } |
| timestamp: Int(Date().addingTimeInterval(-300).timeIntervalSince1970 * 1000) | ||
| ), | ||
| ChatMessage( | ||
| id: 2, | ||
| sessionId: 0, | ||
| role: "assistant", | ||
| content: "Of course! I'd be happy to help you summarize your document. Please share the document or paste the text you'd like me to summarize.", | ||
| timestamp: Int(Date().addingTimeInterval(-240).timeIntervalSince1970 * 1000) | ||
| ), | ||
| ChatMessage( | ||
| id: 3, | ||
| sessionId: 0, | ||
| role: "human", | ||
| content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", | ||
| timestamp: Int(Date().addingTimeInterval(-180).timeIntervalSince1970 * 1000) | ||
| ), | ||
| ChatMessage( | ||
| id: 4, | ||
| sessionId: 0, | ||
| role: "assistant", | ||
| content: "Based on the text you provided, here's a concise summary: The document discusses the classic Lorem Ipsum placeholder text, which has been used in the printing and typesetting industry for centuries as a standard dummy text.", | ||
| timestamp: Int(Date().addingTimeInterval(-120).timeIntervalSince1970 * 1000) |
There was a problem hiding this comment.
Timestamps are handled inconsistently: ChatMessageRequest uses seconds (timeIntervalSince1970), the preview data multiplies by 1000 (milliseconds), and MessageBubbleView interprets message.timestamp as seconds. Align the unit (seconds vs milliseconds) across request, API model, previews, and display so dates don’t render incorrectly.
| timestamp: Int(Date().addingTimeInterval(-300).timeIntervalSince1970 * 1000) | |
| ), | |
| ChatMessage( | |
| id: 2, | |
| sessionId: 0, | |
| role: "assistant", | |
| content: "Of course! I'd be happy to help you summarize your document. Please share the document or paste the text you'd like me to summarize.", | |
| timestamp: Int(Date().addingTimeInterval(-240).timeIntervalSince1970 * 1000) | |
| ), | |
| ChatMessage( | |
| id: 3, | |
| sessionId: 0, | |
| role: "human", | |
| content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", | |
| timestamp: Int(Date().addingTimeInterval(-180).timeIntervalSince1970 * 1000) | |
| ), | |
| ChatMessage( | |
| id: 4, | |
| sessionId: 0, | |
| role: "assistant", | |
| content: "Based on the text you provided, here's a concise summary: The document discusses the classic Lorem Ipsum placeholder text, which has been used in the printing and typesetting industry for centuries as a standard dummy text.", | |
| timestamp: Int(Date().addingTimeInterval(-120).timeIntervalSince1970 * 1000) | |
| timestamp: Int(Date().addingTimeInterval(-300).timeIntervalSince1970) | |
| ), | |
| ChatMessage( | |
| id: 2, | |
| sessionId: 0, | |
| role: "assistant", | |
| content: "Of course! I'd be happy to help you summarize your document. Please share the document or paste the text you'd like me to summarize.", | |
| timestamp: Int(Date().addingTimeInterval(-240).timeIntervalSince1970) | |
| ), | |
| ChatMessage( | |
| id: 3, | |
| sessionId: 0, | |
| role: "human", | |
| content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", | |
| timestamp: Int(Date().addingTimeInterval(-180).timeIntervalSince1970) | |
| ), | |
| ChatMessage( | |
| id: 4, | |
| sessionId: 0, | |
| role: "assistant", | |
| content: "Based on the text you provided, here's a concise summary: The document discusses the classic Lorem Ipsum placeholder text, which has been used in the printing and typesetting industry for centuries as a standard dummy text.", | |
| timestamp: Int(Date().addingTimeInterval(-120).timeIntervalSince1970) |
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Prerequisite: nextcloud/NextcloudKit#209
Implements nextcloud/files-clients#111