Skip to content

Assistant Chat#3978

Open
mpivchev wants to merge 39 commits intomasterfrom
assistant-improvements
Open

Assistant Chat#3978
mpivchev wants to merge 39 commits intomasterfrom
assistant-improvements

Conversation

@mpivchev
Copy link
Collaborator

@mpivchev mpivchev commented Feb 9, 2026

Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
…ant-improvements

Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
@mpivchev mpivchev linked an issue Feb 9, 2026 that may be closed by this pull request
6 tasks
@mpivchev mpivchev marked this pull request as ready for review February 12, 2026 09:19
@mpivchev mpivchev enabled auto-merge (squash) February 12, 2026 09:20
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 NCAssistant to 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 calling handle(...), leaving isLoading stuck. Make sure isLoading is cleared and an error is reported when the server returns no task / conversion fails.
    iOSClient/Assistant/NCAssistantModel.swift:170
  • loadAllTypes non-V2 path returns early on guard let types else { return } without clearing isLoading. If the API returns types == nil (even alongside an error), the UI can remain stuck loading. Route all exit paths through the handle(...) function (or clear isLoading before returning).
    Tests/NextcloudUITests/AssistantUITests.swift:59
  • createTask now taps ConversationsButton, 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 calling handle(...), which means isLoading stays true and 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 loadAllTasks V2 path, guard let tasks = result.tasks?.tasks.filter(...) else { return } can exit without resetting isLoading / 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 with item.url == "openAssistant", but the corresponding branch was removed from didSelectRowAt. As a result, tapping Assistant from the More tab won’t do anything (it falls through to applicationHandle.didSelectItem, which is a no-op by default). Either re-add the openAssistant presentation logic here, or remove the openAssistant item 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, isLoading is set to true before the Task {}. When useV2 is true and selectedType is nil, the guard let selectedType else { return } exits without ever resetting isLoading, leaving the UI stuck in loading state. Guard selectedType before setting isLoading, or ensure the early-return path sets isLoading = false.
    iOSClient/Assistant/NCAssistantModel.swift:246
  • loadDummyData() sets the “Chat” type id to "1", but the runtime chat detection uses chatTypeId = "core:text2text:chat". This makes previews/tests of the chat UI behave incorrectly (chat will never be treated as selected). Consider using chatTypeId for the dummy Chat type id so isSelectedTypeChat matches.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 116 to 130
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
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +136 to +138
guard let conversation = await sessionsModel.createNewConversation(title: input) else { return }
await selectConversation(selectedConversation: conversation)
sendMessage(input: input)
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
Comment on lines 150 to 171
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)
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
@mpivchev mpivchev disabled auto-merge February 12, 2026 16:46
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat with AI

1 participant