From 463c424c0d194a4168d9de298f5e48aa9b18557e Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 30 Jan 2026 22:06:38 +0100 Subject: [PATCH 01/42] feat: add assistant tray prompt --- src/gui/tray/MainWindow.qml | 101 +++++++--- src/gui/tray/usermodel.cpp | 264 +++++++++++++++++++++++++- src/gui/tray/usermodel.h | 38 ++++ src/libsync/CMakeLists.txt | 2 + src/libsync/ocsassistantconnector.cpp | 191 +++++++++++++++++++ src/libsync/ocsassistantconnector.h | 51 +++++ 6 files changed, 623 insertions(+), 24 deletions(-) create mode 100644 src/libsync/ocsassistantconnector.cpp create mode 100644 src/libsync/ocsassistantconnector.h diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index c70bdf34ac5bf..3896a1eea4767 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -401,22 +401,77 @@ ApplicationWindow { } } - SyncStatus { - id: syncStatus - - accentColor: Style.accentColor - visible: !trayWindowMainItem.isUnifiedSearchActive - - anchors.top: trayWindowUnifiedSearchInputContainer.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - } - - Rectangle { - id: syncStatusSeparator - anchors.left: syncStatus.left - anchors.right: syncStatus.right - anchors.bottom: syncStatus.bottom + SyncStatus { + id: syncStatus + + accentColor: Style.accentColor + visible: !trayWindowMainItem.isUnifiedSearchActive + + anchors.top: trayWindowUnifiedSearchInputContainer.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + } + + Loader { + id: assistantPromptLoader + + active: !trayWindowMainItem.isUnifiedSearchActive && UserModel.currentUser.isAssistantEnabled + anchors.top: syncStatus.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.topMargin: Style.trayHorizontalMargin + anchors.leftMargin: Style.trayHorizontalMargin + anchors.rightMargin: Style.trayHorizontalMargin + + sourceComponent: ColumnLayout { + id: assistantPrompt + spacing: Style.smallSpacing + + function submitQuestion() { + if (assistantQuestionInput.text.trim().length === 0) { + return; + } + UserModel.currentUser.submitAssistantQuestion(assistantQuestionInput.text) + assistantQuestionInput.text = "" + } + + TextField { + id: assistantQuestionInput + Layout.fillWidth: true + placeholderText: qsTr("Ask Assistant…") + enabled: UserModel.currentUser.isConnected && !UserModel.currentUser.assistantRequestInProgress + onAccepted: assistantPrompt.submitQuestion() + } + + Button { + text: qsTr("Send") + enabled: assistantQuestionInput.text.length > 0 && !UserModel.currentUser.assistantRequestInProgress + onClicked: assistantPrompt.submitQuestion() + } + + Label { + Layout.fillWidth: true + visible: UserModel.currentUser.assistantResponse.length > 0 + text: UserModel.currentUser.assistantResponse + wrapMode: Text.Wrap + color: palette.windowText + } + + Label { + Layout.fillWidth: true + visible: UserModel.currentUser.assistantError.length > 0 + text: UserModel.currentUser.assistantError + wrapMode: Text.Wrap + color: palette.highlight + } + } + } + + Rectangle { + id: syncStatusSeparator + anchors.left: syncStatus.left + anchors.right: syncStatus.right + anchors.bottom: syncStatus.bottom height: 1 color: palette.dark visible: !trayWindowMainItem.isUnifiedSearchActive @@ -475,13 +530,13 @@ ApplicationWindow { } } - ActivityList { - id: activityList - visible: !trayWindowMainItem.isUnifiedSearchActive - anchors.top: syncStatus.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.bottom: trayWindowMainItem.bottom + ActivityList { + id: activityList + visible: !trayWindowMainItem.isUnifiedSearchActive + anchors.top: assistantPromptLoader.active ? assistantPromptLoader.bottom : syncStatus.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.bottom: trayWindowMainItem.bottom activeFocusOnTab: true model: activityModel diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index d9a2510bf5b45..2d34c67dfd04e 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -24,6 +24,7 @@ #include "tray/talkreply.h" #include "userstatusconnector.h" #include "common/utility.h" +#include "ocsassistantconnector.h" #include #include @@ -41,6 +42,73 @@ namespace { constexpr qint64 expiredActivitiesCheckIntervalMsecs = 1000 * 60; constexpr qint64 activityDefaultExpirationTimeMsecs = 1000 * 60 * 10; +constexpr qint64 assistantPollIntervalMsecs = 2000; +constexpr int assistantSuccessMinStatusCode = 200; +constexpr int assistantSuccessMaxStatusCode = 300; + +QString assistantTaskTypeIdFromResponse(const QJsonDocument &json) +{ + const auto types = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject().value("types"_L1).toArray(); + QString fallbackId; + for (const auto &entry : types) { + const auto typeObject = entry.toObject(); + const auto typeId = typeObject.value("id"_L1).toString(); + if (typeId.isEmpty()) { + continue; + } + if (typeObject.value("appId"_L1).toString() == "assistant"_L1) { + return typeId; + } + if (fallbackId.isEmpty()) { + fallbackId = typeId; + } + } + return fallbackId; +} + +qint64 assistantTaskIdFromSchedule(const QJsonDocument &json) +{ + const auto task = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject().value("task"_L1).toObject(); + return static_cast(task.value("id"_L1).toDouble(-1)); +} + +QString assistantOutputFromTask(const QJsonObject &task) +{ + const auto outputValue = task.value("output"_L1); + if (outputValue.isString()) { + return outputValue.toString(); + } + + if (outputValue.isObject()) { + const auto outputObject = outputValue.toObject(); + const auto nestedOutput = outputObject.value("output"_L1); + if (nestedOutput.isString()) { + return nestedOutput.toString(); + } + if (nestedOutput.isObject()) { + const auto nestedObject = nestedOutput.toObject(); + const auto textValue = nestedObject.value("text"_L1); + if (textValue.isString()) { + return textValue.toString(); + } + const auto answerValue = nestedObject.value("answer"_L1); + if (answerValue.isString()) { + return answerValue.toString(); + } + } + + const auto textValue = outputObject.value("text"_L1); + if (textValue.isString()) { + return textValue.toString(); + } + const auto answerValue = outputObject.value("answer"_L1); + if (answerValue.isString()) { + return answerValue.toString(); + } + } + + return QString(); +} } namespace OCC { @@ -97,6 +165,7 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::headerColorChanged); connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::headerTextColorChanged); connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::accentColorChanged); + connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::assistantStateChanged); connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::slotAccountCapabilitiesChangedRefreshGroupFolders); @@ -122,6 +191,10 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) showDesktopNotification(certificateNeedMigration); } }); + + _assistantPollTimer.setInterval(assistantPollIntervalMsecs); + _assistantPollTimer.setSingleShot(false); + connect(&_assistantPollTimer, &QTimer::timeout, this, &User::slotAssistantPoll); } void User::checkNotifiedNotifications() @@ -1103,6 +1176,26 @@ bool User::isNcAssistantEnabled() const return _account->account()->capabilities().ncAssistantEnabled(); } +QString User::assistantQuestion() const +{ + return _assistantQuestion; +} + +QString User::assistantResponse() const +{ + return _assistantResponse; +} + +QString User::assistantError() const +{ + return _assistantError; +} + +bool User::assistantRequestInProgress() const +{ + return _assistantRequestInProgress; +} + QColor User::headerColor() const { return _account->account()->headerColor(); @@ -1159,6 +1252,176 @@ void User::slotSendReplyMessage(const int activityIndex, const QString &token, c }); } +void User::submitAssistantQuestion(const QString &question) +{ + const auto trimmedQuestion = question.trimmed(); + if (trimmedQuestion.isEmpty()) { + return; + } + + if (!isNcAssistantEnabled()) { + _assistantError = tr("Assistant is not available for this account."); + emit assistantErrorChanged(); + return; + } + + if (!_assistantConnector) { + _assistantConnector = new OcsAssistantConnector(_account->account(), this); + connect(_assistantConnector, &OcsAssistantConnector::taskTypesFetched, this, &User::slotAssistantTaskTypesFetched); + connect(_assistantConnector, &OcsAssistantConnector::tasksFetched, this, &User::slotAssistantTasksFetched); + connect(_assistantConnector, &OcsAssistantConnector::taskScheduled, this, &User::slotAssistantTaskScheduled); + connect(_assistantConnector, &OcsAssistantConnector::taskDeleted, this, &User::slotAssistantTaskDeleted); + connect(_assistantConnector, &OcsAssistantConnector::requestError, this, &User::slotAssistantRequestError); + } + + _assistantQuestion = trimmedQuestion; + emit assistantQuestionChanged(); + + _assistantError.clear(); + emit assistantErrorChanged(); + + _assistantResponse = tr("Sending your request…"); + emit assistantResponseChanged(); + + _assistantRequestInProgress = true; + emit assistantRequestInProgressChanged(); + + _assistantPollAttempts = 0; + _assistantTaskId = -1; + + if (_assistantTaskType.isEmpty()) { + _assistantConnector->fetchTaskTypes(); + return; + } + + _assistantConnector->scheduleTask(_assistantQuestion, _assistantTaskType); +} + +void User::clearAssistantResponse() +{ + if (_assistantResponse.isEmpty() && _assistantError.isEmpty() && _assistantQuestion.isEmpty()) { + return; + } + _assistantQuestion.clear(); + _assistantResponse.clear(); + _assistantError.clear(); + emit assistantQuestionChanged(); + emit assistantResponseChanged(); + emit assistantErrorChanged(); +} + +void User::slotAssistantPoll() +{ + if (!_assistantConnector || _assistantTaskType.isEmpty()) { + _assistantPollTimer.stop(); + return; + } + + if (_assistantPollAttempts >= _assistantMaxPollAttempts) { + _assistantPollTimer.stop(); + _assistantRequestInProgress = false; + emit assistantRequestInProgressChanged(); + if (_assistantResponse.isEmpty()) { + _assistantResponse = tr("No response yet. Please try again later."); + emit assistantResponseChanged(); + } + return; + } + + ++_assistantPollAttempts; + _assistantConnector->fetchTasks(_assistantTaskType); +} + +void User::slotAssistantTaskTypesFetched(const QJsonDocument &json, int statusCode) +{ + if (statusCode < assistantSuccessMinStatusCode || statusCode >= assistantSuccessMaxStatusCode) { + slotAssistantRequestError(QStringLiteral("taskTypes"), statusCode); + return; + } + + _assistantTaskType = assistantTaskTypeIdFromResponse(json); + if (_assistantTaskType.isEmpty()) { + _assistantError = tr("No supported assistant task types were returned."); + emit assistantErrorChanged(); + _assistantRequestInProgress = false; + emit assistantRequestInProgressChanged(); + return; + } + + _assistantConnector->scheduleTask(_assistantQuestion, _assistantTaskType); +} + +void User::slotAssistantTasksFetched(const QJsonDocument &json, int statusCode) +{ + if (statusCode < assistantSuccessMinStatusCode || statusCode >= assistantSuccessMaxStatusCode) { + slotAssistantRequestError(QStringLiteral("tasks"), statusCode); + return; + } + + const auto tasks = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject().value("tasks"_L1).toArray(); + QString output; + for (const auto &entry : tasks) { + const auto taskObject = entry.toObject(); + const auto taskId = static_cast(taskObject.value("id"_L1).toDouble(-1)); + if (_assistantTaskId > 0 && taskId != _assistantTaskId) { + continue; + } + output = assistantOutputFromTask(taskObject); + if (!output.isEmpty()) { + break; + } + } + + if (output.isEmpty()) { + if (!_assistantPollTimer.isActive()) { + _assistantPollAttempts = 0; + _assistantPollTimer.start(); + } + return; + } + + _assistantPollTimer.stop(); + _assistantResponse = output; + emit assistantResponseChanged(); + _assistantRequestInProgress = false; + emit assistantRequestInProgressChanged(); +} + +void User::slotAssistantTaskScheduled(const QJsonDocument &json, int statusCode) +{ + if (statusCode < assistantSuccessMinStatusCode || statusCode >= assistantSuccessMaxStatusCode) { + slotAssistantRequestError(QStringLiteral("schedule"), statusCode); + return; + } + + _assistantTaskId = assistantTaskIdFromSchedule(json); + _assistantResponse = tr("Waiting for the assistant response…"); + emit assistantResponseChanged(); + + _assistantPollAttempts = 0; + if (!_assistantPollTimer.isActive()) { + _assistantPollTimer.start(); + } +} + +void User::slotAssistantTaskDeleted(int statusCode) +{ + if (statusCode >= assistantSuccessMinStatusCode && statusCode < assistantSuccessMaxStatusCode) { + return; + } + slotAssistantRequestError(QStringLiteral("deleteTask"), statusCode); +} + +void User::slotAssistantRequestError(const QString &context, int statusCode) +{ + _assistantPollTimer.stop(); + _assistantRequestInProgress = false; + emit assistantRequestInProgressChanged(); + _assistantError = tr("Assistant request failed (%1).").arg(statusCode); + emit assistantErrorChanged(); + qCWarning(lcActivity) << "Assistant request error:" << context << statusCode; +} + void User::forceSyncNow() const { FolderMan::instance()->forceSyncForFolder(getFolder()); @@ -1904,4 +2167,3 @@ QHash UserAppsModel::roleNames() const return roles; } } - diff --git a/src/gui/tray/usermodel.h b/src/gui/tray/usermodel.h index 611f5dc05dfa3..e8886b0b97801 100644 --- a/src/gui/tray/usermodel.h +++ b/src/gui/tray/usermodel.h @@ -9,9 +9,12 @@ #include #include #include +#include #include #include #include +#include +#include #include "accountfwd.h" #include "accountmanager.h" @@ -25,6 +28,7 @@ namespace OCC { class UnifiedSearchResultsListModel; +class OcsAssistantConnector; class TrayFolderInfo @@ -72,6 +76,11 @@ class User : public QObject Q_PROPERTY(UnifiedSearchResultsListModel* unifiedSearchResultsListModel READ getUnifiedSearchResultsListModel CONSTANT) Q_PROPERTY(QVariantList groupFolders READ groupFolders NOTIFY groupFoldersChanged) Q_PROPERTY(bool canLogout READ canLogout CONSTANT) + Q_PROPERTY(bool isAssistantEnabled READ isNcAssistantEnabled NOTIFY assistantStateChanged) + Q_PROPERTY(QString assistantQuestion READ assistantQuestion NOTIFY assistantQuestionChanged) + Q_PROPERTY(QString assistantResponse READ assistantResponse NOTIFY assistantResponseChanged) + Q_PROPERTY(QString assistantError READ assistantError NOTIFY assistantErrorChanged) + Q_PROPERTY(bool assistantRequestInProgress READ assistantRequestInProgress NOTIFY assistantRequestInProgressChanged) public: User(AccountStatePtr &account, const bool &isCurrent = false, QObject *parent = nullptr); @@ -116,6 +125,13 @@ class User : public QObject [[nodiscard]] const QVariantList &groupFolders() const; [[nodiscard]] bool canLogout() const; [[nodiscard]] bool isPublicShareLink() const; + [[nodiscard]] QString assistantQuestion() const; + [[nodiscard]] QString assistantResponse() const; + [[nodiscard]] QString assistantError() const; + [[nodiscard]] bool assistantRequestInProgress() const; + + Q_INVOKABLE void submitAssistantQuestion(const QString &question); + Q_INVOKABLE void clearAssistantResponse(); signals: void nameChanged(); @@ -130,6 +146,11 @@ class User : public QObject void accentColorChanged(); void sendReplyMessage(const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo); void groupFoldersChanged(); + void assistantStateChanged(); + void assistantQuestionChanged(); + void assistantResponseChanged(); + void assistantErrorChanged(); + void assistantRequestInProgressChanged(); public slots: void slotItemCompleted(const QString &folder, const OCC::SyncFileItemPtr &item); @@ -166,6 +187,12 @@ private slots: void slotCheckExpiredActivities(); void slotGroupFoldersFetched(QNetworkReply *reply); void slotQuotaChanged(const int64_t &usedBytes, const int64_t &availableBytes); + void slotAssistantPoll(); + void slotAssistantTaskTypesFetched(const QJsonDocument &json, int statusCode); + void slotAssistantTasksFetched(const QJsonDocument &json, int statusCode); + void slotAssistantTaskScheduled(const QJsonDocument &json, int statusCode); + void slotAssistantTaskDeleted(int statusCode); + void slotAssistantRequestError(const QString &context, int statusCode); void checkNotifiedNotifications(); void showDesktopNotification(const QString &title, const QString &message, const qint64 notificationId); void showDesktopNotification(const OCC::Activity &activity); @@ -213,6 +240,17 @@ private slots: // used for quota warnings int _lastQuotaPercent = 0; Activity _lastQuotaActivity; + + QPointer _assistantConnector; + QTimer _assistantPollTimer; + int _assistantPollAttempts = 0; + int _assistantMaxPollAttempts = 10; + qint64 _assistantTaskId = -1; + QString _assistantTaskType; + QString _assistantQuestion; + QString _assistantResponse; + QString _assistantError; + bool _assistantRequestInProgress = false; }; class UserModel : public QAbstractListModel diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index 5829eabdcfe70..10429efe187cb 100644 --- a/src/libsync/CMakeLists.txt +++ b/src/libsync/CMakeLists.txt @@ -144,6 +144,8 @@ set(libsync_SRCS rootencryptedfolderinfo.cpp foldermetadata.h foldermetadata.cpp + ocsassistantconnector.h + ocsassistantconnector.cpp ocsuserstatusconnector.h ocsuserstatusconnector.cpp rootencryptedfolderinfo.cpp diff --git a/src/libsync/ocsassistantconnector.cpp b/src/libsync/ocsassistantconnector.cpp new file mode 100644 index 0000000000000..dca43ffe36dfa --- /dev/null +++ b/src/libsync/ocsassistantconnector.cpp @@ -0,0 +1,191 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "ocsassistantconnector.h" + +#include "account.h" +#include "networkjobs.h" + +#include +#include +#include +#include +#include + +using namespace Qt::StringLiterals; + +namespace OCC { + +namespace { + +Q_LOGGING_CATEGORY(lcOcsAssistantConnector, "nextcloud.sync.ocsassistantconnector", QtInfoMsg) + +const auto basePath = QStringLiteral("/ocs/v2.php/taskprocessing"); + +int statusCodeFromJson(const QString &jsonStr, int fallback) +{ + if (jsonStr.contains(""_L1)) { + static const QRegularExpression xmlRegex("(\\d+)"_L1); + const auto match = xmlRegex.match(jsonStr); + if (match.hasMatch()) { + return match.captured(1).toInt(); + } + return fallback; + } + + static const QRegularExpression jsonRegex(R"("statuscode":(\d+))"); + const auto match = jsonRegex.match(jsonStr); + if (match.hasMatch()) { + return match.captured(1).toInt(); + } + + return fallback; +} + +} + +class AssistantApiJob : public SimpleApiJob +{ + Q_OBJECT +public: + explicit AssistantApiJob(const AccountPtr &account, const QString &path, QObject *parent = nullptr) + : SimpleApiJob(account, path, parent) + { + } + + void setFormBody(const QUrlQuery &query) + { + const auto body = query.toString(QUrl::FullyEncoded).toUtf8(); + setBody(body); + request().setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + } + +signals: + void jsonReceived(const QJsonDocument &json, int statusCode); + +protected: + bool finished() override + { + qCInfo(lcOcsAssistantConnector) << "AssistantApiJob of" << reply()->request().url() + << "FINISHED WITH STATUS" << replyStatusString(); + + const auto httpStatusCode = reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply()->error() != QNetworkReply::NoError) { + qCWarning(lcOcsAssistantConnector) << "Network error:" << path() << errorString() << httpStatusCode; + emit jsonReceived(QJsonDocument(), httpStatusCode); + return true; + } + + const QByteArray replyData = reply()->readAll(); + const auto jsonStr = QString::fromUtf8(replyData); + const auto statusCode = statusCodeFromJson(jsonStr, httpStatusCode); + + QJsonParseError error{}; + auto json = QJsonDocument::fromJson(replyData, &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(lcOcsAssistantConnector) << "Invalid JSON response:" << error.errorString(); + } + + emit jsonReceived(json, statusCode); + return true; + } +}; + +OcsAssistantConnector::OcsAssistantConnector(AccountPtr account, QObject *parent) + : QObject(parent) + , _account(std::move(account)) +{ + Q_ASSERT(_account); +} + +void OcsAssistantConnector::fetchTaskTypes() +{ + if (_taskTypesJob) { + qCDebug(lcOcsAssistantConnector) << "Task types job already running."; + return; + } + + _taskTypesJob = new JsonApiJob(_account, basePath + QStringLiteral("/tasktypes"), this); + connect(_taskTypesJob, &JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) { + emitIfError(QStringLiteral("taskTypes"), statusCode); + emit taskTypesFetched(json, statusCode); + }); + _taskTypesJob->start(); +} + +void OcsAssistantConnector::fetchTasks(const QString &taskType) +{ + if (_tasksJob) { + qCDebug(lcOcsAssistantConnector) << "Tasks job already running."; + return; + } + + _tasksJob = new JsonApiJob(_account, basePath + QStringLiteral("/tasks"), this); + QUrlQuery params; + params.addQueryItem(QStringLiteral("taskType"), taskType); + _tasksJob->addQueryParams(params); + connect(_tasksJob, &JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) { + emitIfError(QStringLiteral("tasks"), statusCode); + emit tasksFetched(json, statusCode); + }); + _tasksJob->start(); +} + +void OcsAssistantConnector::scheduleTask(const QString &input, const QString &taskType, const QString &appId, const QString &customId) +{ + if (_scheduleJob) { + qCDebug(lcOcsAssistantConnector) << "Schedule job already running."; + return; + } + + _scheduleJob = new AssistantApiJob(_account, basePath + QStringLiteral("/schedule"), this); + _scheduleJob->setVerb(SimpleApiJob::Verb::Post); + + QUrlQuery params; + params.addQueryItem(QStringLiteral("format"), QStringLiteral("json")); + _scheduleJob->addQueryParams(params); + + QUrlQuery body; + body.addQueryItem(QStringLiteral("input[input]"), input); + body.addQueryItem(QStringLiteral("type"), taskType); + body.addQueryItem(QStringLiteral("appId"), appId); + body.addQueryItem(QStringLiteral("customId"), customId); + _scheduleJob->setFormBody(body); + + connect(_scheduleJob, &AssistantApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) { + emitIfError(QStringLiteral("schedule"), statusCode); + emit taskScheduled(json, statusCode); + }); + _scheduleJob->start(); +} + +void OcsAssistantConnector::deleteTask(qint64 taskId) +{ + if (_deleteJob) { + qCDebug(lcOcsAssistantConnector) << "Delete task job already running."; + return; + } + + const auto path = basePath + QStringLiteral("/task/") + QString::number(taskId); + _deleteJob = new JsonApiJob(_account, path, this); + _deleteJob->setVerb(SimpleApiJob::Verb::Delete); + connect(_deleteJob, &JsonApiJob::jsonReceived, this, [this](const QJsonDocument &, int statusCode) { + emitIfError(QStringLiteral("deleteTask"), statusCode); + emit taskDeleted(statusCode); + }); + _deleteJob->start(); +} + +void OcsAssistantConnector::emitIfError(const QString &context, int statusCode) +{ + if (statusCode < 200 || statusCode >= 300) { + qCWarning(lcOcsAssistantConnector) << "Assistant request failed:" << context << "status" << statusCode; + emit requestError(context, statusCode); + } +} + +} // namespace OCC + +#include "ocsassistantconnector.moc" diff --git a/src/libsync/ocsassistantconnector.h b/src/libsync/ocsassistantconnector.h new file mode 100644 index 0000000000000..da7198320e6f9 --- /dev/null +++ b/src/libsync/ocsassistantconnector.h @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "accountfwd.h" +#include "owncloudlib.h" + +#include +#include +#include +#include + +namespace OCC { + +class JsonApiJob; +class AssistantApiJob; + +class OWNCLOUDSYNC_EXPORT OcsAssistantConnector : public QObject +{ + Q_OBJECT +public: + explicit OcsAssistantConnector(AccountPtr account, QObject *parent = nullptr); + + void fetchTaskTypes(); + void fetchTasks(const QString &taskType); + void scheduleTask(const QString &input, const QString &taskType, + const QString &appId = QStringLiteral("assistant"), + const QString &customId = QString()); + void deleteTask(qint64 taskId); + +signals: + void taskTypesFetched(const QJsonDocument &json, int statusCode); + void tasksFetched(const QJsonDocument &json, int statusCode); + void taskScheduled(const QJsonDocument &json, int statusCode); + void taskDeleted(int statusCode); + void requestError(const QString &context, int statusCode); + +private: + void emitIfError(const QString &context, int statusCode); + + AccountPtr _account; + QPointer _taskTypesJob; + QPointer _tasksJob; + QPointer _scheduleJob; + QPointer _deleteJob; +}; + +} // namespace OCC From 7660ea078218eba02008e9e00dfb0edf939733fd Mon Sep 17 00:00:00 2001 From: Rello Date: Tue, 20 Jan 2026 12:07:32 +0700 Subject: [PATCH 02/42] fix: align connection settings background --- src/gui/networksettings.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/gui/networksettings.cpp b/src/gui/networksettings.cpp index 83f4af87fb962..5a91881fdbe0e 100644 --- a/src/gui/networksettings.cpp +++ b/src/gui/networksettings.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include namespace OCC { @@ -27,6 +28,14 @@ NetworkSettings::NetworkSettings(const AccountPtr &account, QWidget *parent) , _account(account) { _ui->setupUi(this); + setAutoFillBackground(true); + setBackgroundRole(QPalette::AlternateBase); + _ui->proxyGroupBox->setAutoFillBackground(true); + _ui->proxyGroupBox->setBackgroundRole(QPalette::AlternateBase); + _ui->downloadBox->setAutoFillBackground(true); + _ui->downloadBox->setBackgroundRole(QPalette::AlternateBase); + _ui->uploadBox->setAutoFillBackground(true); + _ui->uploadBox->setBackgroundRole(QPalette::AlternateBase); _ui->manualSettings->setVisible(_ui->manualProxyRadioButton->isChecked()); From 086dc95559b008bf43664b15454c98be7195d38f Mon Sep 17 00:00:00 2001 From: Matthieu Gallien Date: Tue, 3 Feb 2026 13:42:18 +0100 Subject: [PATCH 03/42] chore: fix compilation for missing string literals Signed-off-by: Matthieu Gallien --- src/gui/tray/usermodel.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 2d34c67dfd04e..1e1e63a6670ca 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -35,6 +35,8 @@ #include #include +using namespace Qt::StringLiterals; + // time span in milliseconds which has to be between two // refreshes of the notifications #define NOTIFICATION_REQUEST_FREE_PERIOD 15000 From 2d965f6ffcb7f61f9ff1ec12ea4a5dd30e36ded4 Mon Sep 17 00:00:00 2001 From: Matthieu Gallien Date: Tue, 3 Feb 2026 16:53:39 +0100 Subject: [PATCH 04/42] fix: use taskprocessing OCS API to submit prompt to AI known issue is that we forget to wait long enough for the reply we also probably would fail to handle more than one request in parallel so we should prevent submitting more than one at a time Signed-off-by: Matthieu Gallien --- src/gui/tray/usermodel.cpp | 25 ++++++++++++------------- src/libsync/ocsassistantconnector.cpp | 14 +++++++++----- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 1e1e63a6670ca..dea6a682efe9a 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -41,7 +41,10 @@ using namespace Qt::StringLiterals; // refreshes of the notifications #define NOTIFICATION_REQUEST_FREE_PERIOD 15000 +namespace OCC { + namespace { + constexpr qint64 expiredActivitiesCheckIntervalMsecs = 1000 * 60; constexpr qint64 activityDefaultExpirationTimeMsecs = 1000 * 60 * 10; constexpr qint64 assistantPollIntervalMsecs = 2000; @@ -50,22 +53,20 @@ constexpr int assistantSuccessMaxStatusCode = 300; QString assistantTaskTypeIdFromResponse(const QJsonDocument &json) { - const auto types = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject().value("types"_L1).toArray(); - QString fallbackId; - for (const auto &entry : types) { - const auto typeObject = entry.toObject(); - const auto typeId = typeObject.value("id"_L1).toString(); + const auto types = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject().value("types"_L1).toObject(); + auto resultTypeId = QString{}; + for (const auto &typeId : types.keys()) { + const auto typeObject = types[typeId].toObject(); if (typeId.isEmpty()) { continue; } - if (typeObject.value("appId"_L1).toString() == "assistant"_L1) { - return typeId; - } - if (fallbackId.isEmpty()) { - fallbackId = typeId; + if (typeId == "core:text2text"_L1) { + qCDebug(lcActivity) << typeObject << typeId << types[typeId].toObject(); + resultTypeId = typeId; + break; } } - return fallbackId; + return resultTypeId; } qint64 assistantTaskIdFromSchedule(const QJsonDocument &json) @@ -112,8 +113,6 @@ QString assistantOutputFromTask(const QJsonObject &task) return QString(); } } - -namespace OCC { TrayFolderInfo::TrayFolderInfo(const QString &name, const QString &parentPath, const QString &fullPath, FolderType folderType) : _name(name) diff --git a/src/libsync/ocsassistantconnector.cpp b/src/libsync/ocsassistantconnector.cpp index dca43ffe36dfa..43b4007de2344 100644 --- a/src/libsync/ocsassistantconnector.cpp +++ b/src/libsync/ocsassistantconnector.cpp @@ -22,7 +22,7 @@ namespace { Q_LOGGING_CATEGORY(lcOcsAssistantConnector, "nextcloud.sync.ocsassistantconnector", QtInfoMsg) -const auto basePath = QStringLiteral("/ocs/v2.php/taskprocessing"); +const auto basePath = u"/ocs/v2.php/taskprocessing"_s; int statusCodeFromJson(const QString &jsonStr, int fallback) { @@ -107,8 +107,9 @@ void OcsAssistantConnector::fetchTaskTypes() return; } - _taskTypesJob = new JsonApiJob(_account, basePath + QStringLiteral("/tasktypes"), this); + _taskTypesJob = new JsonApiJob(_account, basePath + u"/tasktypes"_s, this); connect(_taskTypesJob, &JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) { + qCInfo(lcOcsAssistantConnector).noquote() << statusCode << QString::fromUtf8(json.toJson(QJsonDocument::JsonFormat::Compact)); emitIfError(QStringLiteral("taskTypes"), statusCode); emit taskTypesFetched(json, statusCode); }); @@ -122,11 +123,12 @@ void OcsAssistantConnector::fetchTasks(const QString &taskType) return; } - _tasksJob = new JsonApiJob(_account, basePath + QStringLiteral("/tasks"), this); + _tasksJob = new JsonApiJob(_account, u"ocs/v2.php/apps/assistant/api/v1/tasks"_s, this); QUrlQuery params; params.addQueryItem(QStringLiteral("taskType"), taskType); _tasksJob->addQueryParams(params); connect(_tasksJob, &JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) { + qCInfo(lcOcsAssistantConnector).noquote() << statusCode << QString::fromUtf8(json.toJson(QJsonDocument::JsonFormat::Compact)); emitIfError(QStringLiteral("tasks"), statusCode); emit tasksFetched(json, statusCode); }); @@ -155,6 +157,7 @@ void OcsAssistantConnector::scheduleTask(const QString &input, const QString &ta _scheduleJob->setFormBody(body); connect(_scheduleJob, &AssistantApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) { + qCInfo(lcOcsAssistantConnector).noquote() << statusCode << QString::fromUtf8(json.toJson(QJsonDocument::JsonFormat::Compact)); emitIfError(QStringLiteral("schedule"), statusCode); emit taskScheduled(json, statusCode); }); @@ -168,10 +171,11 @@ void OcsAssistantConnector::deleteTask(qint64 taskId) return; } - const auto path = basePath + QStringLiteral("/task/") + QString::number(taskId); + const auto path = QString{basePath + QStringLiteral("/task/") + QString::number(taskId)}; _deleteJob = new JsonApiJob(_account, path, this); _deleteJob->setVerb(SimpleApiJob::Verb::Delete); - connect(_deleteJob, &JsonApiJob::jsonReceived, this, [this](const QJsonDocument &, int statusCode) { + connect(_deleteJob, &JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) { + qCInfo(lcOcsAssistantConnector).noquote() << statusCode << QString::fromUtf8(json.toJson(QJsonDocument::JsonFormat::Compact)); emitIfError(QStringLiteral("deleteTask"), statusCode); emit taskDeleted(statusCode); }); From 678305128236d1a87081caacb7ea2aaad298bbd9 Mon Sep 17 00:00:00 2001 From: Rello Date: Wed, 4 Feb 2026 10:58:02 +0100 Subject: [PATCH 05/42] fix: send assistant chat history Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 208 +++++++++++++++----------- src/gui/tray/usermodel.cpp | 71 ++++++++- src/gui/tray/usermodel.h | 6 +- src/libsync/ocsassistantconnector.cpp | 6 +- src/libsync/ocsassistantconnector.h | 2 +- 5 files changed, 200 insertions(+), 93 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 3896a1eea4767..6ea71da8d6e67 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -225,18 +225,21 @@ ApplicationWindow { } } - Rectangle { - id: trayWindowMainItem - - property bool isUnifiedSearchActive: unifiedSearchResultsListViewSkeletonLoader.active - || unifiedSearchResultNothingFound.visible - || unifiedSearchResultsErrorLabel.visible - || unifiedSearchResultsListView.visible - || trayWindowUnifiedSearchInputContainer.activateSearchFocus - - anchors.fill: parent - anchors.margins: Style.trayWindowBorderWidth - clip: true + Rectangle { + id: trayWindowMainItem + + property bool isUnifiedSearchActive: unifiedSearchResultsListViewSkeletonLoader.active + || unifiedSearchResultNothingFound.visible + || unifiedSearchResultsErrorLabel.visible + || unifiedSearchResultsListView.visible + || trayWindowUnifiedSearchInputContainer.activateSearchFocus + property bool isAssistantActive: assistantPromptLoader.active + && assistantPromptLoader.item + && assistantPromptLoader.item.isAssistantActive + + anchors.fill: parent + anchors.margins: Style.trayWindowBorderWidth + clip: true radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius color: Style.colorWithoutTransparency(palette.base) @@ -258,8 +261,8 @@ ApplicationWindow { height: Style.trayWindowHeaderHeight } - UnifiedSearchInputContainer { - id: trayWindowUnifiedSearchInputContainer + UnifiedSearchInputContainer { + id: trayWindowUnifiedSearchInputContainer property bool activateSearchFocus: activeFocus @@ -275,14 +278,104 @@ ApplicationWindow { isSearchInProgress: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress onTextEdited: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = trayWindowUnifiedSearchInputContainer.text } onClearText: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = "" } - onActiveFocusChanged: activateSearchFocus = activeFocus && focusReason !== Qt.TabFocusReason && focusReason !== Qt.BacktabFocusReason - Keys.onEscapePressed: activateSearchFocus = false - } - - Rectangle { - id: bottomUnifiedSearchInputSeparator - - anchors.top: trayWindowUnifiedSearchInputContainer.bottom + onActiveFocusChanged: activateSearchFocus = activeFocus && focusReason !== Qt.TabFocusReason && focusReason !== Qt.BacktabFocusReason + Keys.onEscapePressed: activateSearchFocus = false + } + + Loader { + id: assistantPromptLoader + + active: UserModel.currentUser.isAssistantEnabled && !trayWindowMainItem.isUnifiedSearchActive + anchors.top: trayWindowUnifiedSearchInputContainer.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.topMargin: Style.trayHorizontalMargin + anchors.leftMargin: Style.trayHorizontalMargin + anchors.rightMargin: Style.trayHorizontalMargin + implicitHeight: assistantPromptLoader.item ? assistantPromptLoader.item.implicitHeight : 0 + height: trayWindowMainItem.isAssistantActive ? trayWindowMainItem.height - y : implicitHeight + clip: trayWindowMainItem.isAssistantActive + + sourceComponent: ColumnLayout { + id: assistantPrompt + spacing: Style.smallSpacing + + property bool isAssistantActive: assistantQuestionInput.activeFocus + || assistantConversationList.count > 0 + || assistantStatusLabel.visible + || assistantErrorLabel.visible + + function submitQuestion() { + if (assistantQuestionInput.text.trim().length === 0) { + return; + } + UserModel.currentUser.submitAssistantQuestion(assistantQuestionInput.text) + assistantQuestionInput.text = "" + } + + TextField { + id: assistantQuestionInput + Layout.fillWidth: true + placeholderText: qsTr("Ask Assistant…") + enabled: UserModel.currentUser.isConnected && !UserModel.currentUser.assistantRequestInProgress + onAccepted: assistantPrompt.submitQuestion() + } + + ListView { + id: assistantConversationList + Layout.fillWidth: true + Layout.fillHeight: assistantPrompt.isAssistantActive + clip: true + spacing: Style.smallSpacing + visible: count > 0 + + model: UserModel.currentUser.assistantMessages + + delegate: ColumnLayout { + width: assistantConversationList.width + spacing: Style.extraSmallSpacing + + Label { + Layout.fillWidth: true + text: modelData.role === "assistant" ? qsTr("Assistant") : qsTr("You") + color: palette.mid + font.bold: true + } + + Text { + Layout.fillWidth: true + text: modelData.text + wrapMode: Text.Wrap + color: palette.windowText + textFormat: Text.MarkdownText + } + } + } + + Label { + id: assistantStatusLabel + Layout.fillWidth: true + visible: UserModel.currentUser.assistantResponse.length > 0 + text: UserModel.currentUser.assistantResponse + wrapMode: Text.Wrap + color: palette.windowText + } + + Label { + id: assistantErrorLabel + Layout.fillWidth: true + visible: UserModel.currentUser.assistantError.length > 0 + text: UserModel.currentUser.assistantError + wrapMode: Text.Wrap + color: palette.highlight + } + } + } + + Rectangle { + id: bottomUnifiedSearchInputSeparator + + anchors.top: trayWindowUnifiedSearchInputContainer.bottom anchors.left: parent.left anchors.right: parent.right anchors.topMargin: Style.trayHorizontalMargin @@ -405,66 +498,11 @@ ApplicationWindow { id: syncStatus accentColor: Style.accentColor - visible: !trayWindowMainItem.isUnifiedSearchActive - - anchors.top: trayWindowUnifiedSearchInputContainer.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - } - - Loader { - id: assistantPromptLoader + visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.isAssistantActive - active: !trayWindowMainItem.isUnifiedSearchActive && UserModel.currentUser.isAssistantEnabled - anchors.top: syncStatus.bottom + anchors.top: assistantPromptLoader.active ? assistantPromptLoader.bottom : trayWindowUnifiedSearchInputContainer.bottom anchors.left: trayWindowMainItem.left anchors.right: trayWindowMainItem.right - anchors.topMargin: Style.trayHorizontalMargin - anchors.leftMargin: Style.trayHorizontalMargin - anchors.rightMargin: Style.trayHorizontalMargin - - sourceComponent: ColumnLayout { - id: assistantPrompt - spacing: Style.smallSpacing - - function submitQuestion() { - if (assistantQuestionInput.text.trim().length === 0) { - return; - } - UserModel.currentUser.submitAssistantQuestion(assistantQuestionInput.text) - assistantQuestionInput.text = "" - } - - TextField { - id: assistantQuestionInput - Layout.fillWidth: true - placeholderText: qsTr("Ask Assistant…") - enabled: UserModel.currentUser.isConnected && !UserModel.currentUser.assistantRequestInProgress - onAccepted: assistantPrompt.submitQuestion() - } - - Button { - text: qsTr("Send") - enabled: assistantQuestionInput.text.length > 0 && !UserModel.currentUser.assistantRequestInProgress - onClicked: assistantPrompt.submitQuestion() - } - - Label { - Layout.fillWidth: true - visible: UserModel.currentUser.assistantResponse.length > 0 - text: UserModel.currentUser.assistantResponse - wrapMode: Text.Wrap - color: palette.windowText - } - - Label { - Layout.fillWidth: true - visible: UserModel.currentUser.assistantError.length > 0 - text: UserModel.currentUser.assistantError - wrapMode: Text.Wrap - color: palette.highlight - } - } } Rectangle { @@ -472,10 +510,10 @@ ApplicationWindow { anchors.left: syncStatus.left anchors.right: syncStatus.right anchors.bottom: syncStatus.bottom - height: 1 - color: palette.dark - visible: !trayWindowMainItem.isUnifiedSearchActive - } + height: 1 + color: palette.dark + visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.isAssistantActive + } Loader { id: newActivitiesButtonLoader @@ -532,8 +570,8 @@ ApplicationWindow { ActivityList { id: activityList - visible: !trayWindowMainItem.isUnifiedSearchActive - anchors.top: assistantPromptLoader.active ? assistantPromptLoader.bottom : syncStatus.bottom + visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.isAssistantActive + anchors.top: syncStatus.bottom anchors.left: trayWindowMainItem.left anchors.right: trayWindowMainItem.right anchors.bottom: trayWindowMainItem.bottom diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index dea6a682efe9a..034c5aaf38fd7 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -55,18 +55,23 @@ QString assistantTaskTypeIdFromResponse(const QJsonDocument &json) { const auto types = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject().value("types"_L1).toObject(); auto resultTypeId = QString{}; + auto fallbackTypeId = QString{}; for (const auto &typeId : types.keys()) { const auto typeObject = types[typeId].toObject(); if (typeId.isEmpty()) { continue; } - if (typeId == "core:text2text"_L1) { + if (typeId == "core:text2text:chat"_L1) { qCDebug(lcActivity) << typeObject << typeId << types[typeId].toObject(); resultTypeId = typeId; break; } + if (typeId == "core:text2text"_L1) { + qCDebug(lcActivity) << typeObject << typeId << types[typeId].toObject(); + fallbackTypeId = typeId; + } } - return resultTypeId; + return resultTypeId.isEmpty() ? fallbackTypeId : resultTypeId; } qint64 assistantTaskIdFromSchedule(const QJsonDocument &json) @@ -1192,6 +1197,11 @@ QString User::assistantError() const return _assistantError; } +QVariantList User::assistantMessages() const +{ + return _assistantMessages; +} + bool User::assistantRequestInProgress() const { return _assistantRequestInProgress; @@ -1266,6 +1276,12 @@ void User::submitAssistantQuestion(const QString &question) return; } + if (_assistantRequestInProgress) { + _assistantError = tr("Assistant is already processing a request."); + emit assistantErrorChanged(); + return; + } + if (!_assistantConnector) { _assistantConnector = new OcsAssistantConnector(_account->account(), this); connect(_assistantConnector, &OcsAssistantConnector::taskTypesFetched, this, &User::slotAssistantTaskTypesFetched); @@ -1275,6 +1291,19 @@ void User::submitAssistantQuestion(const QString &question) connect(_assistantConnector, &OcsAssistantConnector::requestError, this, &User::slotAssistantRequestError); } + QStringList history; + history.reserve(_assistantMessages.size()); + for (int index = _assistantMessages.size() - 1; index >= 0; --index) { + const auto entry = _assistantMessages.at(index).toMap(); + const auto role = entry.value(QStringLiteral("role")).toString(); + const auto text = entry.value(QStringLiteral("text")).toString(); + if (text.isEmpty()) { + continue; + } + const auto historyRole = (role == QLatin1String("assistant")) ? QStringLiteral("Assistant") : QStringLiteral("User"); + history.append(QStringLiteral("%1: %2").arg(historyRole, text)); + } + _assistantQuestion = trimmedQuestion; emit assistantQuestionChanged(); @@ -1284,6 +1313,12 @@ void User::submitAssistantQuestion(const QString &question) _assistantResponse = tr("Sending your request…"); emit assistantResponseChanged(); + _assistantMessages.prepend(QVariantMap{ + {QStringLiteral("role"), QStringLiteral("user")}, + {QStringLiteral("text"), _assistantQuestion}, + }); + emit assistantMessagesChanged(); + _assistantRequestInProgress = true; emit assistantRequestInProgressChanged(); @@ -1295,20 +1330,22 @@ void User::submitAssistantQuestion(const QString &question) return; } - _assistantConnector->scheduleTask(_assistantQuestion, _assistantTaskType); + _assistantConnector->scheduleTask(_assistantQuestion, _assistantTaskType, history); } void User::clearAssistantResponse() { - if (_assistantResponse.isEmpty() && _assistantError.isEmpty() && _assistantQuestion.isEmpty()) { + if (_assistantResponse.isEmpty() && _assistantError.isEmpty() && _assistantQuestion.isEmpty() && _assistantMessages.isEmpty()) { return; } _assistantQuestion.clear(); _assistantResponse.clear(); _assistantError.clear(); + _assistantMessages.clear(); emit assistantQuestionChanged(); emit assistantResponseChanged(); emit assistantErrorChanged(); + emit assistantMessagesChanged(); } void User::slotAssistantPoll() @@ -1349,7 +1386,19 @@ void User::slotAssistantTaskTypesFetched(const QJsonDocument &json, int statusCo return; } - _assistantConnector->scheduleTask(_assistantQuestion, _assistantTaskType); + QStringList history; + history.reserve(_assistantMessages.size()); + for (int index = _assistantMessages.size() - 1; index >= 1; --index) { + const auto entry = _assistantMessages.at(index).toMap(); + const auto role = entry.value(QStringLiteral("role")).toString(); + const auto text = entry.value(QStringLiteral("text")).toString(); + if (text.isEmpty()) { + continue; + } + const auto historyRole = (role == QLatin1String("assistant")) ? QStringLiteral("Assistant") : QStringLiteral("User"); + history.append(QStringLiteral("%1: %2").arg(historyRole, text)); + } + _assistantConnector->scheduleTask(_assistantQuestion, _assistantTaskType, history); } void User::slotAssistantTasksFetched(const QJsonDocument &json, int statusCode) @@ -1361,6 +1410,7 @@ void User::slotAssistantTasksFetched(const QJsonDocument &json, int statusCode) const auto tasks = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject().value("tasks"_L1).toArray(); QString output; + qint64 taskIdToDelete = -1; for (const auto &entry : tasks) { const auto taskObject = entry.toObject(); const auto taskId = static_cast(taskObject.value("id"_L1).toDouble(-1)); @@ -1369,6 +1419,7 @@ void User::slotAssistantTasksFetched(const QJsonDocument &json, int statusCode) } output = assistantOutputFromTask(taskObject); if (!output.isEmpty()) { + taskIdToDelete = taskId; break; } } @@ -1384,8 +1435,18 @@ void User::slotAssistantTasksFetched(const QJsonDocument &json, int statusCode) _assistantPollTimer.stop(); _assistantResponse = output; emit assistantResponseChanged(); + _assistantMessages.prepend(QVariantMap{ + {QStringLiteral("role"), QStringLiteral("assistant")}, + {QStringLiteral("text"), _assistantResponse}, + }); + emit assistantMessagesChanged(); + _assistantResponse.clear(); + emit assistantResponseChanged(); _assistantRequestInProgress = false; emit assistantRequestInProgressChanged(); + if (taskIdToDelete > 0) { + _assistantConnector->deleteTask(taskIdToDelete); + } } void User::slotAssistantTaskScheduled(const QJsonDocument &json, int statusCode) diff --git a/src/gui/tray/usermodel.h b/src/gui/tray/usermodel.h index e8886b0b97801..5e266898faa4e 100644 --- a/src/gui/tray/usermodel.h +++ b/src/gui/tray/usermodel.h @@ -80,6 +80,7 @@ class User : public QObject Q_PROPERTY(QString assistantQuestion READ assistantQuestion NOTIFY assistantQuestionChanged) Q_PROPERTY(QString assistantResponse READ assistantResponse NOTIFY assistantResponseChanged) Q_PROPERTY(QString assistantError READ assistantError NOTIFY assistantErrorChanged) + Q_PROPERTY(QVariantList assistantMessages READ assistantMessages NOTIFY assistantMessagesChanged) Q_PROPERTY(bool assistantRequestInProgress READ assistantRequestInProgress NOTIFY assistantRequestInProgressChanged) public: @@ -128,6 +129,7 @@ class User : public QObject [[nodiscard]] QString assistantQuestion() const; [[nodiscard]] QString assistantResponse() const; [[nodiscard]] QString assistantError() const; + [[nodiscard]] QVariantList assistantMessages() const; [[nodiscard]] bool assistantRequestInProgress() const; Q_INVOKABLE void submitAssistantQuestion(const QString &question); @@ -150,6 +152,7 @@ class User : public QObject void assistantQuestionChanged(); void assistantResponseChanged(); void assistantErrorChanged(); + void assistantMessagesChanged(); void assistantRequestInProgressChanged(); public slots: @@ -244,12 +247,13 @@ private slots: QPointer _assistantConnector; QTimer _assistantPollTimer; int _assistantPollAttempts = 0; - int _assistantMaxPollAttempts = 10; + int _assistantMaxPollAttempts = 60; qint64 _assistantTaskId = -1; QString _assistantTaskType; QString _assistantQuestion; QString _assistantResponse; QString _assistantError; + QVariantList _assistantMessages; bool _assistantRequestInProgress = false; }; diff --git a/src/libsync/ocsassistantconnector.cpp b/src/libsync/ocsassistantconnector.cpp index 43b4007de2344..495a9ae655bf3 100644 --- a/src/libsync/ocsassistantconnector.cpp +++ b/src/libsync/ocsassistantconnector.cpp @@ -135,7 +135,8 @@ void OcsAssistantConnector::fetchTasks(const QString &taskType) _tasksJob->start(); } -void OcsAssistantConnector::scheduleTask(const QString &input, const QString &taskType, const QString &appId, const QString &customId) +void OcsAssistantConnector::scheduleTask(const QString &input, const QString &taskType, const QStringList &history, + const QString &appId, const QString &customId) { if (_scheduleJob) { qCDebug(lcOcsAssistantConnector) << "Schedule job already running."; @@ -151,6 +152,9 @@ void OcsAssistantConnector::scheduleTask(const QString &input, const QString &ta QUrlQuery body; body.addQueryItem(QStringLiteral("input[input]"), input); + for (int index = 0; index < history.size(); ++index) { + body.addQueryItem(QStringLiteral("input[history][%1][input]").arg(index), history.at(index)); + } body.addQueryItem(QStringLiteral("type"), taskType); body.addQueryItem(QStringLiteral("appId"), appId); body.addQueryItem(QStringLiteral("customId"), customId); diff --git a/src/libsync/ocsassistantconnector.h b/src/libsync/ocsassistantconnector.h index da7198320e6f9..2cdb3ed11ab33 100644 --- a/src/libsync/ocsassistantconnector.h +++ b/src/libsync/ocsassistantconnector.h @@ -26,7 +26,7 @@ class OWNCLOUDSYNC_EXPORT OcsAssistantConnector : public QObject void fetchTaskTypes(); void fetchTasks(const QString &taskType); - void scheduleTask(const QString &input, const QString &taskType, + void scheduleTask(const QString &input, const QString &taskType, const QStringList &history, const QString &appId = QStringLiteral("assistant"), const QString &customId = QString()); void deleteTask(qint64 taskId); From 96eae7b0de4e50d5c43beedaea3efe92f2cfd345 Mon Sep 17 00:00:00 2001 From: Rello Date: Wed, 4 Feb 2026 12:00:11 +0100 Subject: [PATCH 06/42] fix: avoid overriding assistant loader implicit height Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 6ea71da8d6e67..e4228423d44f0 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -292,7 +292,6 @@ ApplicationWindow { anchors.topMargin: Style.trayHorizontalMargin anchors.leftMargin: Style.trayHorizontalMargin anchors.rightMargin: Style.trayHorizontalMargin - implicitHeight: assistantPromptLoader.item ? assistantPromptLoader.item.implicitHeight : 0 height: trayWindowMainItem.isAssistantActive ? trayWindowMainItem.height - y : implicitHeight clip: trayWindowMainItem.isAssistantActive From f5cd9426a45dba57d20fb408998b34f114b677d5 Mon Sep 17 00:00:00 2001 From: Rello Date: Wed, 4 Feb 2026 12:27:15 +0100 Subject: [PATCH 07/42] fix: include assistant system prompt Signed-off-by: Rello --- src/libsync/ocsassistantconnector.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/libsync/ocsassistantconnector.cpp b/src/libsync/ocsassistantconnector.cpp index 495a9ae655bf3..7abf226087ec3 100644 --- a/src/libsync/ocsassistantconnector.cpp +++ b/src/libsync/ocsassistantconnector.cpp @@ -23,6 +23,12 @@ namespace { Q_LOGGING_CATEGORY(lcOcsAssistantConnector, "nextcloud.sync.ocsassistantconnector", QtInfoMsg) const auto basePath = u"/ocs/v2.php/taskprocessing"_s; +const auto assistantSystemPrompt = QStringLiteral( + "This is a conversation in a specific language between the user and you, Nextcloud Assistant. " + "You are a kind, polite and helpful AI that helps the user to the best of its abilities. " + "If you do not understand something, you will ask for clarification. Detect the language " + "that the user is using. Make sure to use the same language in your response. Do not mention " + "the language explicitly."); int statusCodeFromJson(const QString &jsonStr, int fallback) { @@ -152,6 +158,7 @@ void OcsAssistantConnector::scheduleTask(const QString &input, const QString &ta QUrlQuery body; body.addQueryItem(QStringLiteral("input[input]"), input); + body.addQueryItem(QStringLiteral("input[system_prompt]"), assistantSystemPrompt); for (int index = 0; index < history.size(); ++index) { body.addQueryItem(QStringLiteral("input[history][%1][input]").arg(index), history.at(index)); } From 7d00ee2d9893ec45c7a7ac4818a70283578d4d19 Mon Sep 17 00:00:00 2001 From: Rello Date: Wed, 4 Feb 2026 12:44:39 +0100 Subject: [PATCH 08/42] fix: always send assistant history Signed-off-by: Rello --- src/libsync/ocsassistantconnector.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/libsync/ocsassistantconnector.cpp b/src/libsync/ocsassistantconnector.cpp index 7abf226087ec3..60e8ffcb4f58e 100644 --- a/src/libsync/ocsassistantconnector.cpp +++ b/src/libsync/ocsassistantconnector.cpp @@ -159,8 +159,12 @@ void OcsAssistantConnector::scheduleTask(const QString &input, const QString &ta QUrlQuery body; body.addQueryItem(QStringLiteral("input[input]"), input); body.addQueryItem(QStringLiteral("input[system_prompt]"), assistantSystemPrompt); - for (int index = 0; index < history.size(); ++index) { - body.addQueryItem(QStringLiteral("input[history][%1][input]").arg(index), history.at(index)); + if (history.isEmpty()) { + body.addQueryItem(QStringLiteral("input[history]"), QString()); + } else { + for (int index = 0; index < history.size(); ++index) { + body.addQueryItem(QStringLiteral("input[history][%1][input]").arg(index), history.at(index)); + } } body.addQueryItem(QStringLiteral("type"), taskType); body.addQueryItem(QStringLiteral("appId"), appId); From 31854b179c77b7eaefe9a3867104b474dcae79f6 Mon Sep 17 00:00:00 2001 From: Rello Date: Wed, 4 Feb 2026 14:56:50 +0100 Subject: [PATCH 09/42] fix: send assistant history as json list Signed-off-by: Rello --- src/gui/tray/usermodel.cpp | 16 ++++++++++++---- src/libsync/ocsassistantconnector.cpp | 4 ++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 034c5aaf38fd7..fa64c7fa8e133 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -1300,8 +1300,12 @@ void User::submitAssistantQuestion(const QString &question) if (text.isEmpty()) { continue; } - const auto historyRole = (role == QLatin1String("assistant")) ? QStringLiteral("Assistant") : QStringLiteral("User"); - history.append(QStringLiteral("%1: %2").arg(historyRole, text)); + const auto historyRole = (role == QLatin1String("assistant")) ? QStringLiteral("assistant") : QStringLiteral("human"); + const QJsonObject historyEntry{ + {QStringLiteral("role"), historyRole}, + {QStringLiteral("content"), text}, + }; + history.append(QString::fromUtf8(QJsonDocument(historyEntry).toJson(QJsonDocument::Compact))); } _assistantQuestion = trimmedQuestion; @@ -1395,8 +1399,12 @@ void User::slotAssistantTaskTypesFetched(const QJsonDocument &json, int statusCo if (text.isEmpty()) { continue; } - const auto historyRole = (role == QLatin1String("assistant")) ? QStringLiteral("Assistant") : QStringLiteral("User"); - history.append(QStringLiteral("%1: %2").arg(historyRole, text)); + const auto historyRole = (role == QLatin1String("assistant")) ? QStringLiteral("assistant") : QStringLiteral("human"); + const QJsonObject historyEntry{ + {QStringLiteral("role"), historyRole}, + {QStringLiteral("content"), text}, + }; + history.append(QString::fromUtf8(QJsonDocument(historyEntry).toJson(QJsonDocument::Compact))); } _assistantConnector->scheduleTask(_assistantQuestion, _assistantTaskType, history); } diff --git a/src/libsync/ocsassistantconnector.cpp b/src/libsync/ocsassistantconnector.cpp index 60e8ffcb4f58e..08d845de70012 100644 --- a/src/libsync/ocsassistantconnector.cpp +++ b/src/libsync/ocsassistantconnector.cpp @@ -160,10 +160,10 @@ void OcsAssistantConnector::scheduleTask(const QString &input, const QString &ta body.addQueryItem(QStringLiteral("input[input]"), input); body.addQueryItem(QStringLiteral("input[system_prompt]"), assistantSystemPrompt); if (history.isEmpty()) { - body.addQueryItem(QStringLiteral("input[history]"), QString()); + body.addQueryItem(QStringLiteral("input[history]"), QStringLiteral("[]")); } else { for (int index = 0; index < history.size(); ++index) { - body.addQueryItem(QStringLiteral("input[history][%1][input]").arg(index), history.at(index)); + body.addQueryItem(QStringLiteral("input[history][%1]").arg(index), history.at(index)); } } body.addQueryItem(QStringLiteral("type"), taskType); From f5feecd0d3bb0e061a39a00d09db9f7b3f16b11b Mon Sep 17 00:00:00 2001 From: Matthieu Gallien Date: Wed, 4 Feb 2026 17:06:29 +0100 Subject: [PATCH 10/42] chore: properly handle completed tasks (success or error) delete completed tasks being success or error should not matter in case of error assistant reply is empty Signed-off-by: Matthieu Gallien --- src/gui/tray/usermodel.cpp | 23 +++++++++++++++++++---- src/libsync/ocsassistantconnector.cpp | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index fa64c7fa8e133..b4c0fbfd62948 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -80,6 +80,21 @@ qint64 assistantTaskIdFromSchedule(const QJsonDocument &json) return static_cast(task.value("id"_L1).toDouble(-1)); } +bool assistantTaskStillRunning(const QJsonObject &task) +{ + auto result = true; + + if (!task.contains(u"status"_s)) { + return result; + } + if (task.value(u"status"_s).toString() == u"STATUS_FAILED"_s || task.value(u"status"_s).toString() == u"STATUS_SUCCESSFUL"_s) { + result = false; + } + qCDebug(lcActivity) << task.value(u"status"_s).toString(); + + return result; +} + QString assistantOutputFromTask(const QJsonObject &task) { const auto outputValue = task.value("output"_L1); @@ -1417,8 +1432,8 @@ void User::slotAssistantTasksFetched(const QJsonDocument &json, int statusCode) } const auto tasks = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject().value("tasks"_L1).toArray(); - QString output; - qint64 taskIdToDelete = -1; + auto output = QString{}; + auto taskIdToDelete = qint64{-1}; for (const auto &entry : tasks) { const auto taskObject = entry.toObject(); const auto taskId = static_cast(taskObject.value("id"_L1).toDouble(-1)); @@ -1426,13 +1441,13 @@ void User::slotAssistantTasksFetched(const QJsonDocument &json, int statusCode) continue; } output = assistantOutputFromTask(taskObject); - if (!output.isEmpty()) { + if (!assistantTaskStillRunning(taskObject)) { taskIdToDelete = taskId; break; } } - if (output.isEmpty()) { + if (taskIdToDelete == -1) { if (!_assistantPollTimer.isActive()) { _assistantPollAttempts = 0; _assistantPollTimer.start(); diff --git a/src/libsync/ocsassistantconnector.cpp b/src/libsync/ocsassistantconnector.cpp index 08d845de70012..9a6fb787045ae 100644 --- a/src/libsync/ocsassistantconnector.cpp +++ b/src/libsync/ocsassistantconnector.cpp @@ -160,7 +160,7 @@ void OcsAssistantConnector::scheduleTask(const QString &input, const QString &ta body.addQueryItem(QStringLiteral("input[input]"), input); body.addQueryItem(QStringLiteral("input[system_prompt]"), assistantSystemPrompt); if (history.isEmpty()) { - body.addQueryItem(QStringLiteral("input[history]"), QStringLiteral("[]")); + body.addQueryItem(QStringLiteral("input[history][1]"), u"[\"{\\\"role\\\": \\\"human\\\", \\\"content\\\": \\\"%1\\\"}\"]"_s.arg(input)); } else { for (int index = 0; index < history.size(); ++index) { body.addQueryItem(QStringLiteral("input[history][%1]").arg(index), history.at(index)); From 6b6a40f300bed736565c1577cc6db39ef381857c Mon Sep 17 00:00:00 2001 From: Rello Date: Wed, 4 Feb 2026 17:14:45 +0100 Subject: [PATCH 11/42] make the role in normal coloring Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 365 ++++++++++++++++++------------------ 1 file changed, 183 insertions(+), 182 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index e4228423d44f0..b48c7712719c6 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -121,50 +121,50 @@ ApplicationWindow { } } - Drawer { - id: userStatusDrawer - width: parent.width - height: parent.height - Style.trayDrawerMargin - padding: 0 + Drawer { + id: userStatusDrawer + width: parent.width + height: parent.height - Style.trayDrawerMargin + padding: 0 edge: Qt.BottomEdge modal: true visible: false - - background: Rectangle { - radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - border.width: Style.trayWindowBorderWidth - border.color: palette.dark - color: Style.colorWithoutTransparency(palette.base) - } - - property int userIndex: 0 - property string modeSetStatus: "setStatus" - property string modeStatusMessage: "statusMessage" - property string initialMode: modeSetStatus - - function openUserStatusDrawer(index, mode) { - console.log(`About to show dialog for user with index ${index}`); - userIndex = index; - initialMode = mode ? mode : modeSetStatus; - open(); - } - - function openUserStatusMessageDrawer(index) { - openUserStatusDrawer(index, modeStatusMessage); - } - - Loader { - id: userStatusContents - anchors.fill: parent - active: userStatusDrawer.visible - sourceComponent: UserStatusSelectorPage { - anchors.fill: parent - userIndex: userStatusDrawer.userIndex - mode: userStatusDrawer.initialMode - onFinished: userStatusDrawer.close() - } - } - } + + background: Rectangle { + radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius + border.width: Style.trayWindowBorderWidth + border.color: palette.dark + color: Style.colorWithoutTransparency(palette.base) + } + + property int userIndex: 0 + property string modeSetStatus: "setStatus" + property string modeStatusMessage: "statusMessage" + property string initialMode: modeSetStatus + + function openUserStatusDrawer(index, mode) { + console.log(`About to show dialog for user with index ${index}`); + userIndex = index; + initialMode = mode ? mode : modeSetStatus; + open(); + } + + function openUserStatusMessageDrawer(index) { + openUserStatusDrawer(index, modeStatusMessage); + } + + Loader { + id: userStatusContents + anchors.fill: parent + active: userStatusDrawer.visible + sourceComponent: UserStatusSelectorPage { + anchors.fill: parent + userIndex: userStatusDrawer.userIndex + mode: userStatusDrawer.initialMode + onFinished: userStatusDrawer.close() + } + } + } Drawer { id: fileDetailsDrawer @@ -225,21 +225,21 @@ ApplicationWindow { } } - Rectangle { - id: trayWindowMainItem - - property bool isUnifiedSearchActive: unifiedSearchResultsListViewSkeletonLoader.active - || unifiedSearchResultNothingFound.visible - || unifiedSearchResultsErrorLabel.visible - || unifiedSearchResultsListView.visible - || trayWindowUnifiedSearchInputContainer.activateSearchFocus - property bool isAssistantActive: assistantPromptLoader.active - && assistantPromptLoader.item - && assistantPromptLoader.item.isAssistantActive - - anchors.fill: parent - anchors.margins: Style.trayWindowBorderWidth - clip: true + Rectangle { + id: trayWindowMainItem + + property bool isUnifiedSearchActive: unifiedSearchResultsListViewSkeletonLoader.active + || unifiedSearchResultNothingFound.visible + || unifiedSearchResultsErrorLabel.visible + || unifiedSearchResultsListView.visible + || trayWindowUnifiedSearchInputContainer.activateSearchFocus + property bool isAssistantActive: assistantPromptLoader.active + && assistantPromptLoader.item + && assistantPromptLoader.item.isAssistantActive + + anchors.fill: parent + anchors.margins: Style.trayWindowBorderWidth + clip: true radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius color: Style.colorWithoutTransparency(palette.base) @@ -261,8 +261,8 @@ ApplicationWindow { height: Style.trayWindowHeaderHeight } - UnifiedSearchInputContainer { - id: trayWindowUnifiedSearchInputContainer + UnifiedSearchInputContainer { + id: trayWindowUnifiedSearchInputContainer property bool activateSearchFocus: activeFocus @@ -278,103 +278,103 @@ ApplicationWindow { isSearchInProgress: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress onTextEdited: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = trayWindowUnifiedSearchInputContainer.text } onClearText: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = "" } - onActiveFocusChanged: activateSearchFocus = activeFocus && focusReason !== Qt.TabFocusReason && focusReason !== Qt.BacktabFocusReason - Keys.onEscapePressed: activateSearchFocus = false - } - - Loader { - id: assistantPromptLoader - - active: UserModel.currentUser.isAssistantEnabled && !trayWindowMainItem.isUnifiedSearchActive - anchors.top: trayWindowUnifiedSearchInputContainer.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.topMargin: Style.trayHorizontalMargin - anchors.leftMargin: Style.trayHorizontalMargin - anchors.rightMargin: Style.trayHorizontalMargin - height: trayWindowMainItem.isAssistantActive ? trayWindowMainItem.height - y : implicitHeight - clip: trayWindowMainItem.isAssistantActive - - sourceComponent: ColumnLayout { - id: assistantPrompt - spacing: Style.smallSpacing - - property bool isAssistantActive: assistantQuestionInput.activeFocus - || assistantConversationList.count > 0 - || assistantStatusLabel.visible - || assistantErrorLabel.visible - - function submitQuestion() { - if (assistantQuestionInput.text.trim().length === 0) { - return; - } - UserModel.currentUser.submitAssistantQuestion(assistantQuestionInput.text) - assistantQuestionInput.text = "" - } - - TextField { - id: assistantQuestionInput - Layout.fillWidth: true - placeholderText: qsTr("Ask Assistant…") - enabled: UserModel.currentUser.isConnected && !UserModel.currentUser.assistantRequestInProgress - onAccepted: assistantPrompt.submitQuestion() - } - - ListView { - id: assistantConversationList - Layout.fillWidth: true - Layout.fillHeight: assistantPrompt.isAssistantActive - clip: true - spacing: Style.smallSpacing - visible: count > 0 - - model: UserModel.currentUser.assistantMessages - - delegate: ColumnLayout { - width: assistantConversationList.width - spacing: Style.extraSmallSpacing - - Label { - Layout.fillWidth: true - text: modelData.role === "assistant" ? qsTr("Assistant") : qsTr("You") - color: palette.mid - font.bold: true - } - - Text { - Layout.fillWidth: true - text: modelData.text - wrapMode: Text.Wrap - color: palette.windowText - textFormat: Text.MarkdownText - } - } - } - - Label { - id: assistantStatusLabel - Layout.fillWidth: true - visible: UserModel.currentUser.assistantResponse.length > 0 - text: UserModel.currentUser.assistantResponse - wrapMode: Text.Wrap - color: palette.windowText - } - - Label { - id: assistantErrorLabel - Layout.fillWidth: true - visible: UserModel.currentUser.assistantError.length > 0 - text: UserModel.currentUser.assistantError - wrapMode: Text.Wrap - color: palette.highlight - } - } - } - - Rectangle { - id: bottomUnifiedSearchInputSeparator - - anchors.top: trayWindowUnifiedSearchInputContainer.bottom + onActiveFocusChanged: activateSearchFocus = activeFocus && focusReason !== Qt.TabFocusReason && focusReason !== Qt.BacktabFocusReason + Keys.onEscapePressed: activateSearchFocus = false + } + + Loader { + id: assistantPromptLoader + + active: UserModel.currentUser.isAssistantEnabled && !trayWindowMainItem.isUnifiedSearchActive + anchors.top: trayWindowUnifiedSearchInputContainer.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.topMargin: Style.trayHorizontalMargin + anchors.leftMargin: Style.trayHorizontalMargin + anchors.rightMargin: Style.trayHorizontalMargin + height: trayWindowMainItem.isAssistantActive ? trayWindowMainItem.height - y : implicitHeight + clip: trayWindowMainItem.isAssistantActive + + sourceComponent: ColumnLayout { + id: assistantPrompt + spacing: Style.smallSpacing + + property bool isAssistantActive: assistantQuestionInput.activeFocus + || assistantConversationList.count > 0 + || assistantStatusLabel.visible + || assistantErrorLabel.visible + + function submitQuestion() { + if (assistantQuestionInput.text.trim().length === 0) { + return; + } + UserModel.currentUser.submitAssistantQuestion(assistantQuestionInput.text) + assistantQuestionInput.text = "" + } + + TextField { + id: assistantQuestionInput + Layout.fillWidth: true + placeholderText: qsTr("Ask Assistant…") + enabled: UserModel.currentUser.isConnected && !UserModel.currentUser.assistantRequestInProgress + onAccepted: assistantPrompt.submitQuestion() + } + + ListView { + id: assistantConversationList + Layout.fillWidth: true + Layout.fillHeight: assistantPrompt.isAssistantActive + clip: true + spacing: Style.smallSpacing + visible: count > 0 + + model: UserModel.currentUser.assistantMessages + + delegate: ColumnLayout { + width: assistantConversationList.width + spacing: Style.extraSmallSpacing + + Label { + Layout.fillWidth: true + text: modelData.role === "assistant" ? qsTr("Assistant") : qsTr("You") + color: palette.windowText + font.bold: true + } + + Text { + Layout.fillWidth: true + text: modelData.text + wrapMode: Text.Wrap + color: palette.windowText + textFormat: Text.MarkdownText + } + } + } + + Label { + id: assistantStatusLabel + Layout.fillWidth: true + visible: UserModel.currentUser.assistantResponse.length > 0 + text: UserModel.currentUser.assistantResponse + wrapMode: Text.Wrap + color: palette.windowText + } + + Label { + id: assistantErrorLabel + Layout.fillWidth: true + visible: UserModel.currentUser.assistantError.length > 0 + text: UserModel.currentUser.assistantError + wrapMode: Text.Wrap + color: palette.highlight + } + } + } + + Rectangle { + id: bottomUnifiedSearchInputSeparator + + anchors.top: trayWindowUnifiedSearchInputContainer.bottom anchors.left: parent.left anchors.right: parent.right anchors.topMargin: Style.trayHorizontalMargin @@ -493,26 +493,26 @@ ApplicationWindow { } } - SyncStatus { - id: syncStatus - - accentColor: Style.accentColor - visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.isAssistantActive - - anchors.top: assistantPromptLoader.active ? assistantPromptLoader.bottom : trayWindowUnifiedSearchInputContainer.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - } - - Rectangle { - id: syncStatusSeparator - anchors.left: syncStatus.left - anchors.right: syncStatus.right - anchors.bottom: syncStatus.bottom - height: 1 - color: palette.dark - visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.isAssistantActive - } + SyncStatus { + id: syncStatus + + accentColor: Style.accentColor + visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.isAssistantActive + + anchors.top: assistantPromptLoader.active ? assistantPromptLoader.bottom : trayWindowUnifiedSearchInputContainer.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + } + + Rectangle { + id: syncStatusSeparator + anchors.left: syncStatus.left + anchors.right: syncStatus.right + anchors.bottom: syncStatus.bottom + height: 1 + color: palette.dark + visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.isAssistantActive + } Loader { id: newActivitiesButtonLoader @@ -567,13 +567,13 @@ ApplicationWindow { } } - ActivityList { - id: activityList - visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.isAssistantActive - anchors.top: syncStatus.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.bottom: trayWindowMainItem.bottom + ActivityList { + id: activityList + visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.isAssistantActive + anchors.top: syncStatus.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.bottom: trayWindowMainItem.bottom activeFocusOnTab: true model: activityModel @@ -592,3 +592,4 @@ ApplicationWindow { } } // Item trayWindowMainItem } + From e2cdbfed447020c366fcb377d43d730f7a266249 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 5 Feb 2026 11:29:32 +0100 Subject: [PATCH 12/42] Assistant conversation bubble Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 53 +++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index b48c7712719c6..8776c923f083b 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -330,23 +330,47 @@ ApplicationWindow { model: UserModel.currentUser.assistantMessages - delegate: ColumnLayout { + delegate: Item { width: assistantConversationList.width - spacing: Style.extraSmallSpacing + implicitHeight: messageRow.implicitHeight - Label { - Layout.fillWidth: true - text: modelData.role === "assistant" ? qsTr("Assistant") : qsTr("You") - color: palette.windowText - font.bold: true - } + readonly property bool isAssistantMessage: modelData.role === "assistant" + + RowLayout { + id: messageRow + + width: parent.width + spacing: Style.smallSpacing + + Item { + Layout.fillWidth: true + visible: !isAssistantMessage + } + + Rectangle { + radius: Style.smallSpacing + color: isAssistantMessage ? palette.base : palette.alternateBase + border.color: palette.mid - Text { - Layout.fillWidth: true - text: modelData.text - wrapMode: Text.Wrap - color: palette.windowText - textFormat: Text.MarkdownText + Layout.maximumWidth: assistantConversationList.width * 0.8 + implicitHeight: messageText.implicitHeight + (Style.smallSpacing * 2) + + Text { + id: messageText + + anchors.fill: parent + anchors.margins: Style.smallSpacing + text: modelData.text + wrapMode: Text.Wrap + color: palette.windowText + textFormat: Text.MarkdownText + } + } + + Item { + Layout.fillWidth: true + visible: isAssistantMessage + } } } } @@ -593,3 +617,4 @@ ApplicationWindow { } // Item trayWindowMainItem } + From 7d64385737ba689304f3bd1288ee94d2bcdd36cc Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 5 Feb 2026 11:33:56 +0100 Subject: [PATCH 13/42] Assistant UI to icon Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 56 ++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 8776c923f083b..77ab3bfe37dca 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -233,9 +233,8 @@ ApplicationWindow { || unifiedSearchResultsErrorLabel.visible || unifiedSearchResultsListView.visible || trayWindowUnifiedSearchInputContainer.activateSearchFocus + property bool showAssistantPanel: false property bool isAssistantActive: assistantPromptLoader.active - && assistantPromptLoader.item - && assistantPromptLoader.item.isAssistantActive anchors.fill: parent anchors.margins: Style.trayWindowBorderWidth @@ -259,6 +258,14 @@ ApplicationWindow { anchors.left: parent.left anchors.right: parent.right height: Style.trayWindowHeaderHeight + + onFeaturedAppButtonClicked: { + if (UserModel.currentUser.isAssistantEnabled) { + trayWindowMainItem.showAssistantPanel = !trayWindowMainItem.showAssistantPanel + } else { + UserModel.openCurrentAccountFeaturedApp() + } + } } UnifiedSearchInputContainer { @@ -282,11 +289,22 @@ ApplicationWindow { Keys.onEscapePressed: activateSearchFocus = false } + Connections { + target: UserModel.currentUser + function onAssistantStateChanged() { + if (!UserModel.currentUser.isAssistantEnabled) { + trayWindowMainItem.showAssistantPanel = false + } + } + } + Loader { id: assistantPromptLoader - active: UserModel.currentUser.isAssistantEnabled && !trayWindowMainItem.isUnifiedSearchActive - anchors.top: trayWindowUnifiedSearchInputContainer.bottom + active: UserModel.currentUser.isAssistantEnabled + && trayWindowMainItem.showAssistantPanel + && !trayWindowMainItem.isUnifiedSearchActive + anchors.top: syncStatus.bottom anchors.left: trayWindowMainItem.left anchors.right: trayWindowMainItem.right anchors.topMargin: Style.trayHorizontalMargin @@ -299,31 +317,10 @@ ApplicationWindow { id: assistantPrompt spacing: Style.smallSpacing - property bool isAssistantActive: assistantQuestionInput.activeFocus - || assistantConversationList.count > 0 - || assistantStatusLabel.visible - || assistantErrorLabel.visible - - function submitQuestion() { - if (assistantQuestionInput.text.trim().length === 0) { - return; - } - UserModel.currentUser.submitAssistantQuestion(assistantQuestionInput.text) - assistantQuestionInput.text = "" - } - - TextField { - id: assistantQuestionInput - Layout.fillWidth: true - placeholderText: qsTr("Ask Assistant…") - enabled: UserModel.currentUser.isConnected && !UserModel.currentUser.assistantRequestInProgress - onAccepted: assistantPrompt.submitQuestion() - } - ListView { id: assistantConversationList Layout.fillWidth: true - Layout.fillHeight: assistantPrompt.isAssistantActive + Layout.fillHeight: true clip: true spacing: Style.smallSpacing visible: count > 0 @@ -521,9 +518,9 @@ ApplicationWindow { id: syncStatus accentColor: Style.accentColor - visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.isAssistantActive + visible: !trayWindowMainItem.isUnifiedSearchActive - anchors.top: assistantPromptLoader.active ? assistantPromptLoader.bottom : trayWindowUnifiedSearchInputContainer.bottom + anchors.top: trayWindowUnifiedSearchInputContainer.bottom anchors.left: trayWindowMainItem.left anchors.right: trayWindowMainItem.right } @@ -535,7 +532,7 @@ ApplicationWindow { anchors.bottom: syncStatus.bottom height: 1 color: palette.dark - visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.isAssistantActive + visible: !trayWindowMainItem.isUnifiedSearchActive } Loader { @@ -618,3 +615,4 @@ ApplicationWindow { } + From ee7ef21fd00af479b3d1b195bf41a09c88bab94d Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 5 Feb 2026 11:35:05 +0100 Subject: [PATCH 14/42] Add signal for featured app button click Signed-off-by: Rello --- src/gui/tray/TrayWindowHeader.qml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/gui/tray/TrayWindowHeader.qml b/src/gui/tray/TrayWindowHeader.qml index bcc1260f648de..c1d687a0c7198 100644 --- a/src/gui/tray/TrayWindowHeader.qml +++ b/src/gui/tray/TrayWindowHeader.qml @@ -16,6 +16,8 @@ import com.nextcloud.desktopclient Rectangle { id: root + signal featuredAppButtonClicked + readonly property alias currentAccountHeaderButton: currentAccountHeaderButton readonly property alias openLocalFolderButton: openLocalFolderButton readonly property alias appsMenu: appsMenu @@ -76,7 +78,7 @@ Rectangle { visible: UserModel.currentUser.isFeaturedAppEnabled icon.source: UserModel.currentUser.featuredAppIcon + "/" + palette.windowText - onClicked: UserModel.openCurrentAccountFeaturedApp() + onClicked: root.featuredAppButtonClicked() Accessible.role: Accessible.Button Accessible.name: UserModel.currentUser.featuredAppAccessibleName From 2b7b39a4b63339173b6d8c065d778464825987ff Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 5 Feb 2026 11:36:36 +0100 Subject: [PATCH 15/42] Remove NcAssistant feature check from user model Removed the check for NcAssistant feature and its associated URL opening. Signed-off-by: Rello --- src/gui/tray/usermodel.cpp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index b4c0fbfd62948..4e3d1b93ad17f 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -1851,13 +1851,6 @@ void UserModel::openCurrentAccountFeaturedApp() return; } - if (currentUser()->isNcAssistantEnabled()) { - auto serverUrl = currentUser()->server(false); - const auto assistanceUrl = serverUrl.append("/apps/assistant/"); - QDesktopServices::openUrl(QUrl::fromUserInput(assistanceUrl)); - return; - } - if (const auto talkApp = currentUser()->talkApp()) { Utility::openBrowser(talkApp->url()); } From ca93b2def4c623deb00c3f4172f89fa06f02f6f9 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 5 Feb 2026 13:38:27 +0100 Subject: [PATCH 16/42] Refactor history handling in ocsassistantconnector Signed-off-by: Rello --- src/libsync/ocsassistantconnector.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libsync/ocsassistantconnector.cpp b/src/libsync/ocsassistantconnector.cpp index 9a6fb787045ae..d15483e4a5114 100644 --- a/src/libsync/ocsassistantconnector.cpp +++ b/src/libsync/ocsassistantconnector.cpp @@ -160,7 +160,11 @@ void OcsAssistantConnector::scheduleTask(const QString &input, const QString &ta body.addQueryItem(QStringLiteral("input[input]"), input); body.addQueryItem(QStringLiteral("input[system_prompt]"), assistantSystemPrompt); if (history.isEmpty()) { - body.addQueryItem(QStringLiteral("input[history][1]"), u"[\"{\\\"role\\\": \\\"human\\\", \\\"content\\\": \\\"%1\\\"}\"]"_s.arg(input)); + const QJsonObject firstHistoryEntry{ + {QStringLiteral("role"), QStringLiteral("human")}, + {QStringLiteral("content"), input}, + }; + body.addQueryItem(QStringLiteral("input[history][0]"), QString::fromUtf8(QJsonDocument(firstHistoryEntry).toJson(QJsonDocument::Compact))); } else { for (int index = 0; index < history.size(); ++index) { body.addQueryItem(QStringLiteral("input[history][%1]").arg(index), history.at(index)); From 1114403ce749c2884ccc4fef6ce4b0c446b3da3e Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 5 Feb 2026 15:46:08 +0100 Subject: [PATCH 17/42] Re-add AI input field Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 61 +++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 77ab3bfe37dca..9b0e628508663 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -262,6 +262,9 @@ ApplicationWindow { onFeaturedAppButtonClicked: { if (UserModel.currentUser.isAssistantEnabled) { trayWindowMainItem.showAssistantPanel = !trayWindowMainItem.showAssistantPanel + if (trayWindowMainItem.showAssistantPanel) { + assistantQuestionInput.forceActiveFocus() + } } else { UserModel.openCurrentAccountFeaturedApp() } @@ -270,6 +273,7 @@ ApplicationWindow { UnifiedSearchInputContainer { id: trayWindowUnifiedSearchInputContainer + visible: !trayWindowMainItem.showAssistantPanel property bool activateSearchFocus: activeFocus @@ -289,6 +293,56 @@ ApplicationWindow { Keys.onEscapePressed: activateSearchFocus = false } + RowLayout { + id: assistantInputContainer + visible: trayWindowMainItem.showAssistantPanel + + function submitQuestion() { + const question = assistantQuestionInput.text.trim() + if (question.length === 0) { + return + } + + UserModel.currentUser.submitAssistantQuestion(question) + assistantQuestionInput.text = "" + } + + anchors.top: trayWindowHeader.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.topMargin: Style.trayHorizontalMargin + anchors.leftMargin: Style.trayHorizontalMargin + anchors.rightMargin: Style.trayHorizontalMargin + spacing: Style.extraSmallSpacing + + TextField { + id: assistantQuestionInput + Layout.fillWidth: true + Layout.preferredHeight: Math.round(trayWindowUnifiedSearchInputContainer.height * 0.8) + placeholderText: qsTr("Ask Assistant…") + enabled: UserModel.currentUser.isConnected && !UserModel.currentUser.assistantRequestInProgress + onAccepted: assistantInputContainer.submitQuestion() + } + + Button { + id: assistantResetButton + Layout.preferredHeight: assistantQuestionInput.height + Layout.preferredWidth: assistantQuestionInput.height + icon.source: "image://svgimage-custom-color/reply.svg/" + palette.windowText + display: AbstractButton.IconOnly + + onClicked: { + UserModel.currentUser.clearAssistantResponse() + assistantQuestionInput.text = "" + assistantQuestionInput.forceActiveFocus() + } + + Accessible.role: Accessible.Button + Accessible.name: qsTr("Start a new assistant chat") + Accessible.onPressAction: assistantResetButton.clicked() + } + } + Connections { target: UserModel.currentUser function onAssistantStateChanged() { @@ -395,14 +449,14 @@ ApplicationWindow { Rectangle { id: bottomUnifiedSearchInputSeparator - anchors.top: trayWindowUnifiedSearchInputContainer.bottom + anchors.top: trayWindowMainItem.showAssistantPanel ? assistantInputContainer.bottom : trayWindowUnifiedSearchInputContainer.bottom anchors.left: parent.left anchors.right: parent.right anchors.topMargin: Style.trayHorizontalMargin height: 1 color: palette.dark - visible: trayWindowMainItem.isUnifiedSearchActive + visible: trayWindowMainItem.isUnifiedSearchActive || trayWindowMainItem.showAssistantPanel } ErrorBox { @@ -520,7 +574,7 @@ ApplicationWindow { accentColor: Style.accentColor visible: !trayWindowMainItem.isUnifiedSearchActive - anchors.top: trayWindowUnifiedSearchInputContainer.bottom + anchors.top: trayWindowMainItem.showAssistantPanel ? assistantInputContainer.bottom : trayWindowUnifiedSearchInputContainer.bottom anchors.left: trayWindowMainItem.left anchors.right: trayWindowMainItem.right } @@ -616,3 +670,4 @@ ApplicationWindow { + From a1498d4db046f5728423300f974c758102948861 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 5 Feb 2026 15:55:12 +0100 Subject: [PATCH 18/42] Refactor clearAssistantResponse for better clarity Signed-off-by: Rello --- src/gui/tray/usermodel.cpp | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 4e3d1b93ad17f..fb63a4309c4cd 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -1354,7 +1354,27 @@ void User::submitAssistantQuestion(const QString &question) void User::clearAssistantResponse() { - if (_assistantResponse.isEmpty() && _assistantError.isEmpty() && _assistantQuestion.isEmpty() && _assistantMessages.isEmpty()) { + const auto hadAssistantData = !_assistantResponse.isEmpty() + || !_assistantError.isEmpty() + || !_assistantQuestion.isEmpty() + || !_assistantMessages.isEmpty(); + + if (_assistantPollTimer.isActive()) { + _assistantPollTimer.stop(); + } + + const auto taskIdToDelete = _assistantTaskId; + _assistantTaskId = -1; + + if (_assistantRequestInProgress) { + _assistantRequestInProgress = false; + emit assistantRequestInProgressChanged(); + } + + if (!hadAssistantData) { + if (_assistantConnector && taskIdToDelete > 0) { + _assistantConnector->deleteTask(taskIdToDelete); + } return; } _assistantQuestion.clear(); @@ -1365,6 +1385,9 @@ void User::clearAssistantResponse() emit assistantResponseChanged(); emit assistantErrorChanged(); emit assistantMessagesChanged(); + if (_assistantConnector && taskIdToDelete > 0) { + _assistantConnector->deleteTask(taskIdToDelete); + } } void User::slotAssistantPoll() From 0e78c80514ebe92b5edc3cf40d589ab8f2db7726 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 5 Feb 2026 16:45:02 +0100 Subject: [PATCH 19/42] Restore MainWindow.qml file with original content Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 9b0e628508663..4897cddc05d45 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -404,15 +404,18 @@ ApplicationWindow { border.color: palette.mid Layout.maximumWidth: assistantConversationList.width * 0.8 + Layout.preferredWidth: Math.min(assistantConversationList.width * 0.8, messageText.implicitWidth + (Style.smallSpacing * 2)) implicitHeight: messageText.implicitHeight + (Style.smallSpacing * 2) - Text { + Text { id: messageText - anchors.fill: parent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top anchors.margins: Style.smallSpacing text: modelData.text - wrapMode: Text.Wrap + wrapMode: Text.WrapAtWordBoundaryOrAnywhere color: palette.windowText textFormat: Text.MarkdownText } @@ -671,3 +674,4 @@ ApplicationWindow { + From 8ff31268a08a8123c0fdb36979f10b808047cba8 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 6 Feb 2026 11:07:26 +0100 Subject: [PATCH 20/42] Restore original MainWindow.qml content Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 74 ++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 4897cddc05d45..aa852851ae250 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -378,52 +378,49 @@ ApplicationWindow { clip: true spacing: Style.smallSpacing visible: count > 0 + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } model: UserModel.currentUser.assistantMessages + onCountChanged: { + if (count > 0) { + positionViewAtEnd() + } + } + delegate: Item { width: assistantConversationList.width - implicitHeight: messageRow.implicitHeight + implicitHeight: messageBubble.implicitHeight readonly property bool isAssistantMessage: modelData.role === "assistant" - RowLayout { - id: messageRow - - width: parent.width - spacing: Style.smallSpacing - - Item { - Layout.fillWidth: true - visible: !isAssistantMessage - } - - Rectangle { - radius: Style.smallSpacing - color: isAssistantMessage ? palette.base : palette.alternateBase - border.color: palette.mid - - Layout.maximumWidth: assistantConversationList.width * 0.8 - Layout.preferredWidth: Math.min(assistantConversationList.width * 0.8, messageText.implicitWidth + (Style.smallSpacing * 2)) - implicitHeight: messageText.implicitHeight + (Style.smallSpacing * 2) - - Text { - id: messageText - - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: Style.smallSpacing - text: modelData.text - wrapMode: Text.WrapAtWordBoundaryOrAnywhere - color: palette.windowText - textFormat: Text.MarkdownText - } - } - - Item { - Layout.fillWidth: true - visible: isAssistantMessage + Rectangle { + id: messageBubble + + anchors.left: isAssistantMessage ? parent.left : undefined + anchors.right: isAssistantMessage ? undefined : parent.right + + radius: Style.smallSpacing + color: isAssistantMessage ? palette.alternateBase : palette.base + border.color: palette.mid + width: Math.min(assistantConversationList.width * 0.8, messageText.implicitWidth + (Style.smallSpacing * 2)) + implicitHeight: messageText.implicitHeight + (Style.smallSpacing * 2) + + Text { + id: messageText + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.smallSpacing + text: modelData.text + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + color: palette.windowText + textFormat: Text.MarkdownText } } } @@ -675,3 +672,4 @@ ApplicationWindow { + From 14321c3b700df5f69c74b200862d822f8982458f Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 6 Feb 2026 15:48:52 +0100 Subject: [PATCH 21/42] Featured icon will become AI-only icon Signed-off-by: Rello --- src/gui/tray/usermodel.cpp | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 3cab79a0ba45a..c441845965b59 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -1190,20 +1190,17 @@ bool User::serverHasTalk() const bool User::isFeaturedAppEnabled() const { - return isNcAssistantEnabled() || serverHasTalk(); + return isNcAssistantEnabled(); } QString User::featuredAppIcon() const { - return isNcAssistantEnabled() ? "image://svgimage-custom-color/nc-assistant-app.svg" - : "image://svgimage-custom-color/talk-app.svg"; + return "image://svgimage-custom-color/nc-assistant-app.svg"; } QString User::featuredAppAccessibleName() const { - return isNcAssistantEnabled() ? - tr("Open %1 Assistant in browser", "The placeholder will be the application name. Please keep it").arg(APPLICATION_NAME) : - tr("Open %1 Talk in browser", "The placeholder will be the application name. Please keep it").arg(APPLICATION_NAME); + return tr("Open %1 Assistant", "The placeholder will be the application name. Please keep it").arg(APPLICATION_NAME); } AccountApp *User::talkApp() const From c21fa1ee4baaea39806c5ff7dad452757f8b8907 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 6 Feb 2026 15:50:01 +0100 Subject: [PATCH 22/42] featured icon will become AI only icon Signed-off-by: Rello --- src/gui/tray/TrayWindowHeader.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gui/tray/TrayWindowHeader.qml b/src/gui/tray/TrayWindowHeader.qml index c1d687a0c7198..c8cb84399da0a 100644 --- a/src/gui/tray/TrayWindowHeader.qml +++ b/src/gui/tray/TrayWindowHeader.qml @@ -73,10 +73,10 @@ Rectangle { id: trayWindowFeaturedAppButton Layout.alignment: Qt.AlignRight - Layout.preferredWidth: Style.trayWindowHeaderHeight + Layout.preferredWidth: Style.trayWindowHeaderHeight Layout.fillHeight: true - visible: UserModel.currentUser.isFeaturedAppEnabled + visible: UserModel.currentUser.isNcAssistantEnabled icon.source: UserModel.currentUser.featuredAppIcon + "/" + palette.windowText onClicked: root.featuredAppButtonClicked() From 835e4347b3f769b079c34032b063647257452b90 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 6 Feb 2026 15:53:14 +0100 Subject: [PATCH 23/42] scrollbar adjustment Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 85 +++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index aa852851ae250..896cc28342240 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -326,9 +326,14 @@ ApplicationWindow { Button { id: assistantResetButton + Layout.alignment: Qt.AlignVCenter Layout.preferredHeight: assistantQuestionInput.height Layout.preferredWidth: assistantQuestionInput.height + Layout.maximumWidth: assistantQuestionInput.height + padding: 0 icon.source: "image://svgimage-custom-color/reply.svg/" + palette.windowText + icon.width: Math.round(assistantQuestionInput.height * 0.5) + icon.height: Math.round(assistantQuestionInput.height * 0.5) display: AbstractButton.IconOnly onClicked: { @@ -371,56 +376,61 @@ ApplicationWindow { id: assistantPrompt spacing: Style.smallSpacing - ListView { - id: assistantConversationList + ScrollView { + id: assistantConversationScrollView Layout.fillWidth: true Layout.fillHeight: true - clip: true - spacing: Style.smallSpacing - visible: count > 0 - boundsBehavior: Flickable.StopAtBounds + visible: assistantConversationList.count > 0 + contentWidth: availableWidth + rightPadding: ScrollBar.vertical.width - ScrollBar.vertical: ScrollBar { - policy: ScrollBar.AsNeeded - } + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AsNeeded + ScrollBar.vertical.width: Math.max(ScrollBar.vertical.implicitWidth, Style.minimumScrollBarWidth) + + ListView { + id: assistantConversationList + clip: true + spacing: Style.smallSpacing + boundsBehavior: Flickable.StopAtBounds - model: UserModel.currentUser.assistantMessages + model: UserModel.currentUser.assistantMessages - onCountChanged: { - if (count > 0) { - positionViewAtEnd() + onCountChanged: { + if (count > 0) { + positionViewAtEnd() + } } - } - delegate: Item { - width: assistantConversationList.width - implicitHeight: messageBubble.implicitHeight + delegate: Item { + width: assistantConversationList.width + implicitHeight: messageBubble.implicitHeight - readonly property bool isAssistantMessage: modelData.role === "assistant" + readonly property bool isAssistantMessage: modelData.role === "assistant" - Rectangle { - id: messageBubble + Rectangle { + id: messageBubble - anchors.left: isAssistantMessage ? parent.left : undefined - anchors.right: isAssistantMessage ? undefined : parent.right + anchors.left: isAssistantMessage ? parent.left : undefined + anchors.right: isAssistantMessage ? undefined : parent.right - radius: Style.smallSpacing - color: isAssistantMessage ? palette.alternateBase : palette.base - border.color: palette.mid - width: Math.min(assistantConversationList.width * 0.8, messageText.implicitWidth + (Style.smallSpacing * 2)) - implicitHeight: messageText.implicitHeight + (Style.smallSpacing * 2) + radius: Style.smallSpacing + color: isAssistantMessage ? palette.alternateBase : palette.base + width: Math.min(assistantConversationList.width * 0.8, messageText.implicitWidth + (Style.smallSpacing * 2)) + implicitHeight: messageText.implicitHeight + (Style.smallSpacing * 2) - Text { - id: messageText + Text { + id: messageText - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: Style.smallSpacing - text: modelData.text - wrapMode: Text.WrapAtWordBoundaryOrAnywhere - color: palette.windowText - textFormat: Text.MarkdownText + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.smallSpacing + text: modelData.text + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + color: palette.windowText + textFormat: Text.MarkdownText + } } } } @@ -673,3 +683,4 @@ ApplicationWindow { + From 26602c6fd67df495645ff00ccf039bb2082b86f7 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 6 Feb 2026 15:58:59 +0100 Subject: [PATCH 24/42] no auto scrolling required Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 896cc28342240..ce8ed4c5aa94a 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -396,12 +396,6 @@ ApplicationWindow { model: UserModel.currentUser.assistantMessages - onCountChanged: { - if (count > 0) { - positionViewAtEnd() - } - } - delegate: Item { width: assistantConversationList.width implicitHeight: messageBubble.implicitHeight @@ -684,3 +678,4 @@ ApplicationWindow { + From c928fa5c7b7c9ef74df5d7bb6b63d77a64278bec Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 6 Feb 2026 16:01:20 +0100 Subject: [PATCH 25/42] hide other warnings when AI is active Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index ce8ed4c5aa94a..afcc76653fae6 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -363,7 +363,7 @@ ApplicationWindow { active: UserModel.currentUser.isAssistantEnabled && trayWindowMainItem.showAssistantPanel && !trayWindowMainItem.isUnifiedSearchActive - anchors.top: syncStatus.bottom + anchors.top: trayWindowMainItem.showAssistantPanel ? assistantInputContainer.bottom : syncStatus.bottom anchors.left: trayWindowMainItem.left anchors.right: trayWindowMainItem.right anchors.topMargin: Style.trayHorizontalMargin @@ -576,7 +576,7 @@ ApplicationWindow { id: syncStatus accentColor: Style.accentColor - visible: !trayWindowMainItem.isUnifiedSearchActive + visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.showAssistantPanel anchors.top: trayWindowMainItem.showAssistantPanel ? assistantInputContainer.bottom : trayWindowUnifiedSearchInputContainer.bottom anchors.left: trayWindowMainItem.left @@ -590,7 +590,7 @@ ApplicationWindow { anchors.bottom: syncStatus.bottom height: 1 color: palette.dark - visible: !trayWindowMainItem.isUnifiedSearchActive + visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.showAssistantPanel } Loader { @@ -679,3 +679,4 @@ ApplicationWindow { + From feb6e55cb9eedb412d5d93dddecf5f1097228b6c Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 6 Feb 2026 16:31:16 +0100 Subject: [PATCH 26/42] Refactor MainWindow.qml for improved structure Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index afcc76653fae6..83798b3e8117d 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -430,7 +430,7 @@ ApplicationWindow { } } - Label { + EnforcedPlainTextLabel { id: assistantStatusLabel Layout.fillWidth: true visible: UserModel.currentUser.assistantResponse.length > 0 @@ -439,7 +439,7 @@ ApplicationWindow { color: palette.windowText } - Label { + EnforcedPlainTextLabel { id: assistantErrorLabel Layout.fillWidth: true visible: UserModel.currentUser.assistantError.length > 0 @@ -680,3 +680,4 @@ ApplicationWindow { + From ebe6e7b244536f38dc78622a725be350931c9235 Mon Sep 17 00:00:00 2001 From: Rello Date: Sun, 8 Feb 2026 19:34:04 +0100 Subject: [PATCH 27/42] make AI Text select-able by mouse Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 83798b3e8117d..96c37625a261b 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -424,6 +424,7 @@ ApplicationWindow { wrapMode: Text.WrapAtWordBoundaryOrAnywhere color: palette.windowText textFormat: Text.MarkdownText + selectByMouse: true } } } @@ -681,3 +682,4 @@ ApplicationWindow { + From 428ef4385ec84d23598ca359c6cecaae30d836c9 Mon Sep 17 00:00:00 2001 From: Rello Date: Sun, 8 Feb 2026 20:01:07 +0100 Subject: [PATCH 28/42] align scrollbar Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 96c37625a261b..1141581ebcd81 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -297,6 +297,12 @@ ApplicationWindow { id: assistantInputContainer visible: trayWindowMainItem.showAssistantPanel + function resetAssistantConversation() { + UserModel.currentUser.clearAssistantResponse() + assistantQuestionInput.text = "" + assistantQuestionInput.forceActiveFocus() + } + function submitQuestion() { const question = assistantQuestionInput.text.trim() if (question.length === 0) { @@ -318,6 +324,7 @@ ApplicationWindow { TextField { id: assistantQuestionInput Layout.fillWidth: true + Layout.minimumWidth: 0 Layout.preferredHeight: Math.round(trayWindowUnifiedSearchInputContainer.height * 0.8) placeholderText: qsTr("Ask Assistant…") enabled: UserModel.currentUser.isConnected && !UserModel.currentUser.assistantRequestInProgress @@ -335,16 +342,13 @@ ApplicationWindow { icon.width: Math.round(assistantQuestionInput.height * 0.5) icon.height: Math.round(assistantQuestionInput.height * 0.5) display: AbstractButton.IconOnly + focusPolicy: Qt.StrongFocus - onClicked: { - UserModel.currentUser.clearAssistantResponse() - assistantQuestionInput.text = "" - assistantQuestionInput.forceActiveFocus() - } + onPressed: assistantInputContainer.resetAssistantConversation() Accessible.role: Accessible.Button Accessible.name: qsTr("Start a new assistant chat") - Accessible.onPressAction: assistantResetButton.clicked() + Accessible.onPressAction: assistantInputContainer.resetAssistantConversation() } } @@ -382,7 +386,8 @@ ApplicationWindow { Layout.fillHeight: true visible: assistantConversationList.count > 0 contentWidth: availableWidth - rightPadding: ScrollBar.vertical.width + leftPadding: 0 + rightPadding: 0 ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ScrollBar.vertical.policy: ScrollBar.AsNeeded @@ -397,7 +402,7 @@ ApplicationWindow { model: UserModel.currentUser.assistantMessages delegate: Item { - width: assistantConversationList.width + width: assistantConversationList.width - assistantConversationScrollView.effectiveScrollBarWidth implicitHeight: messageBubble.implicitHeight readonly property bool isAssistantMessage: modelData.role === "assistant" @@ -683,3 +688,4 @@ ApplicationWindow { + From b3a866ecfe7c5cf5adfaccaa9f1c9a72e22b20f4 Mon Sep 17 00:00:00 2001 From: Rello Date: Sun, 8 Feb 2026 23:46:25 +0100 Subject: [PATCH 29/42] Layout fix Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 1141581ebcd81..1ed32739714f9 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -344,7 +344,7 @@ ApplicationWindow { display: AbstractButton.IconOnly focusPolicy: Qt.StrongFocus - onPressed: assistantInputContainer.resetAssistantConversation() + onClicked: assistantInputContainer.resetAssistantConversation() Accessible.role: Accessible.Button Accessible.name: qsTr("Start a new assistant chat") @@ -402,7 +402,7 @@ ApplicationWindow { model: UserModel.currentUser.assistantMessages delegate: Item { - width: assistantConversationList.width - assistantConversationScrollView.effectiveScrollBarWidth + width: assistantConversationScrollView.availableWidth implicitHeight: messageBubble.implicitHeight readonly property bool isAssistantMessage: modelData.role === "assistant" @@ -689,3 +689,4 @@ ApplicationWindow { + From 7baf57eb31e053a8dc5f675941b6db9a61134069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Bari?= Date: Mon, 9 Feb 2026 10:44:37 +0100 Subject: [PATCH 30/42] fix: Fixing selectable message bubbles. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tamás Bari --- src/gui/tray/MainWindow.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 1ed32739714f9..0fc3967814131 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -418,7 +418,7 @@ ApplicationWindow { width: Math.min(assistantConversationList.width * 0.8, messageText.implicitWidth + (Style.smallSpacing * 2)) implicitHeight: messageText.implicitHeight + (Style.smallSpacing * 2) - Text { + TextEdit { id: messageText anchors.left: parent.left @@ -429,6 +429,7 @@ ApplicationWindow { wrapMode: Text.WrapAtWordBoundaryOrAnywhere color: palette.windowText textFormat: Text.MarkdownText + readOnly: true selectByMouse: true } } From aa3e2510cd0095588b9fe6eb852e820e23cb235e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Bari?= Date: Mon, 9 Feb 2026 22:41:22 +0100 Subject: [PATCH 31/42] feature: more conventional chat window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tamás Bari --- src/gui/tray/MainWindow.qml | 55 +++++++++++++++++++++---------- src/gui/tray/TrayWindowHeader.qml | 2 +- src/gui/tray/usermodel.cpp | 12 +++---- 3 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 0fc3967814131..df5fb0ffc3330 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -237,7 +237,6 @@ ApplicationWindow { property bool isAssistantActive: assistantPromptLoader.active anchors.fill: parent - anchors.margins: Style.trayWindowBorderWidth clip: true radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius @@ -313,22 +312,27 @@ ApplicationWindow { assistantQuestionInput.text = "" } - anchors.top: trayWindowHeader.bottom + anchors.bottom: trayWindowMainItem.bottom anchors.left: trayWindowMainItem.left anchors.right: trayWindowMainItem.right anchors.topMargin: Style.trayHorizontalMargin - anchors.leftMargin: Style.trayHorizontalMargin - anchors.rightMargin: Style.trayHorizontalMargin + anchors.bottomMargin: Style.trayHorizontalMargin spacing: Style.extraSmallSpacing TextField { id: assistantQuestionInput Layout.fillWidth: true Layout.minimumWidth: 0 - Layout.preferredHeight: Math.round(trayWindowUnifiedSearchInputContainer.height * 0.8) + Layout.fillHeight: true placeholderText: qsTr("Ask Assistant…") enabled: UserModel.currentUser.isConnected && !UserModel.currentUser.assistantRequestInProgress onAccepted: assistantInputContainer.submitQuestion() + + Layout.leftMargin: Style.trayHorizontalMargin + leftPadding: 8 + rightPadding: 8 + topPadding: 10 + bottomPadding: 10 } Button { @@ -337,6 +341,7 @@ ApplicationWindow { Layout.preferredHeight: assistantQuestionInput.height Layout.preferredWidth: assistantQuestionInput.height Layout.maximumWidth: assistantQuestionInput.height + Layout.rightMargin: Style.trayHorizontalMargin padding: 0 icon.source: "image://svgimage-custom-color/reply.svg/" + palette.windowText icon.width: Math.round(assistantQuestionInput.height * 0.5) @@ -367,14 +372,13 @@ ApplicationWindow { active: UserModel.currentUser.isAssistantEnabled && trayWindowMainItem.showAssistantPanel && !trayWindowMainItem.isUnifiedSearchActive - anchors.top: trayWindowMainItem.showAssistantPanel ? assistantInputContainer.bottom : syncStatus.bottom + visible: trayWindowMainItem.showAssistantPanel + anchors.top: trayWindowHeader.bottom + anchors.bottom: assistantInputContainer.top anchors.left: trayWindowMainItem.left anchors.right: trayWindowMainItem.right anchors.topMargin: Style.trayHorizontalMargin - anchors.leftMargin: Style.trayHorizontalMargin - anchors.rightMargin: Style.trayHorizontalMargin - height: trayWindowMainItem.isAssistantActive ? trayWindowMainItem.height - y : implicitHeight - clip: trayWindowMainItem.isAssistantActive + clip: true sourceComponent: ColumnLayout { id: assistantPrompt @@ -391,18 +395,21 @@ ApplicationWindow { ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ScrollBar.vertical.policy: ScrollBar.AsNeeded - ScrollBar.vertical.width: Math.max(ScrollBar.vertical.implicitWidth, Style.minimumScrollBarWidth) ListView { id: assistantConversationList clip: true - spacing: Style.smallSpacing + spacing: Style.standardSpacing boundsBehavior: Flickable.StopAtBounds + leftMargin: Style.trayHorizontalMargin + rightMargin: Style.trayHorizontalMargin + model: UserModel.currentUser.assistantMessages delegate: Item { - width: assistantConversationScrollView.availableWidth + id: messageDelegate + width: assistantConversationList.width - ( assistantConversationList.leftMargin + assistantConversationList.rightMargin ) implicitHeight: messageBubble.implicitHeight readonly property bool isAssistantMessage: modelData.role === "assistant" @@ -412,10 +419,12 @@ ApplicationWindow { anchors.left: isAssistantMessage ? parent.left : undefined anchors.right: isAssistantMessage ? undefined : parent.right + anchors.leftMargin: Style.trayHorizontalMargin + anchors.rightMargin: Style.trayHorizontalMargin radius: Style.smallSpacing - color: isAssistantMessage ? palette.alternateBase : palette.base - width: Math.min(assistantConversationList.width * 0.8, messageText.implicitWidth + (Style.smallSpacing * 2)) + color: isAssistantMessage ? palette.alternateBase : palette.highlight + width: Math.min(messageDelegate.width * 0.8, messageText.implicitWidth + (Style.smallSpacing * 2)) implicitHeight: messageText.implicitHeight + (Style.smallSpacing * 2) TextEdit { @@ -427,20 +436,28 @@ ApplicationWindow { anchors.margins: Style.smallSpacing text: modelData.text wrapMode: Text.WrapAtWordBoundaryOrAnywhere - color: palette.windowText + color: isAssistantMessage ? palette.windowText : palette.highlightedText textFormat: Text.MarkdownText readOnly: true selectByMouse: true } } } + + onCountChanged: { + assistantConversationList.positionViewAtEnd() + } } } EnforcedPlainTextLabel { id: assistantStatusLabel Layout.fillWidth: true - visible: UserModel.currentUser.assistantResponse.length > 0 + Layout.leftMargin: Style.trayHorizontalMargin + Layout.rightMargin: Style.trayHorizontalMargin + Layout.bottomMargin: Style.trayHorizontalMargin + Layout.topMargin: Style.trayHorizontalMargin + visible: true text: UserModel.currentUser.assistantResponse wrapMode: Text.Wrap color: palette.windowText @@ -449,6 +466,10 @@ ApplicationWindow { EnforcedPlainTextLabel { id: assistantErrorLabel Layout.fillWidth: true + Layout.leftMargin: Style.trayHorizontalMargin + Layout.rightMargin: Style.trayHorizontalMargin + Layout.bottomMargin: Style.trayHorizontalMargin + Layout.topMargin: Style.trayHorizontalMargin visible: UserModel.currentUser.assistantError.length > 0 text: UserModel.currentUser.assistantError wrapMode: Text.Wrap diff --git a/src/gui/tray/TrayWindowHeader.qml b/src/gui/tray/TrayWindowHeader.qml index c8cb84399da0a..1e2bb20230687 100644 --- a/src/gui/tray/TrayWindowHeader.qml +++ b/src/gui/tray/TrayWindowHeader.qml @@ -76,7 +76,7 @@ Rectangle { Layout.preferredWidth: Style.trayWindowHeaderHeight Layout.fillHeight: true - visible: UserModel.currentUser.isNcAssistantEnabled + visible: UserModel.currentUser.isAssistantEnabled icon.source: UserModel.currentUser.featuredAppIcon + "/" + palette.windowText onClicked: root.featuredAppButtonClicked() diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index ba45276c705d9..ba20528cda11b 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -1349,8 +1349,8 @@ void User::submitAssistantQuestion(const QString &question) QStringList history; history.reserve(_assistantMessages.size()); - for (int index = _assistantMessages.size() - 1; index >= 0; --index) { - const auto entry = _assistantMessages.at(index).toMap(); + for (const auto &message : std::as_const(_assistantMessages)) { + const auto entry = message.toMap(); const auto role = entry.value(QStringLiteral("role")).toString(); const auto text = entry.value(QStringLiteral("text")).toString(); if (text.isEmpty()) { @@ -1373,7 +1373,7 @@ void User::submitAssistantQuestion(const QString &question) _assistantResponse = tr("Sending your request…"); emit assistantResponseChanged(); - _assistantMessages.prepend(QVariantMap{ + _assistantMessages.append(QVariantMap{ {QStringLiteral("role"), QStringLiteral("user")}, {QStringLiteral("text"), _assistantQuestion}, }); @@ -1471,8 +1471,8 @@ void User::slotAssistantTaskTypesFetched(const QJsonDocument &json, int statusCo QStringList history; history.reserve(_assistantMessages.size()); - for (int index = _assistantMessages.size() - 1; index >= 1; --index) { - const auto entry = _assistantMessages.at(index).toMap(); + for (const auto &message : std::as_const(_assistantMessages)) { + const auto entry = message.toMap(); const auto role = entry.value(QStringLiteral("role")).toString(); const auto text = entry.value(QStringLiteral("text")).toString(); if (text.isEmpty()) { @@ -1522,7 +1522,7 @@ void User::slotAssistantTasksFetched(const QJsonDocument &json, int statusCode) _assistantPollTimer.stop(); _assistantResponse = output; emit assistantResponseChanged(); - _assistantMessages.prepend(QVariantMap{ + _assistantMessages.append(QVariantMap{ {QStringLiteral("role"), QStringLiteral("assistant")}, {QStringLiteral("text"), _assistantResponse}, }); From 180564ccc5cc338ee339a8ee69cb9bebd2ab741f Mon Sep 17 00:00:00 2001 From: Rello Date: Tue, 10 Feb 2026 15:10:07 +0100 Subject: [PATCH 32/42] Add files via upload Signed-off-by: Rello --- theme/send.svg | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/theme/send.svg b/theme/send.svg index 8c74eae9c7e2f..870d87713a5db 100644 --- a/theme/send.svg +++ b/theme/send.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file From cc0ed7bf830c3b9aada17c0bc6f5b41edd9ec298 Mon Sep 17 00:00:00 2001 From: Rello Date: Tue, 10 Feb 2026 15:22:03 +0100 Subject: [PATCH 33/42] add send button Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 38 ++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index df5fb0ffc3330..501edda3fb209 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -319,6 +319,16 @@ ApplicationWindow { anchors.bottomMargin: Style.trayHorizontalMargin spacing: Style.extraSmallSpacing + NativeDialogs.MessageDialog { + id: assistantResetConfirmationDialog + + title: Systray.windowTitle + text: qsTr("Start new chat? This will clear the existing conversation") + buttons: NativeDialogs.MessageDialog.Ok | NativeDialogs.MessageDialog.Cancel + + onAccepted: assistantInputContainer.resetAssistantConversation() + } + TextField { id: assistantQuestionInput Layout.fillWidth: true @@ -335,6 +345,27 @@ ApplicationWindow { bottomPadding: 10 } + Button { + id: assistantSendButton + Layout.alignment: Qt.AlignVCenter + Layout.preferredHeight: assistantQuestionInput.height + Layout.preferredWidth: assistantQuestionInput.height + Layout.maximumWidth: assistantQuestionInput.height + padding: 0 + enabled: assistantQuestionInput.enabled && assistantQuestionInput.text.trim().length > 0 + icon.source: "image://svgimage-custom-color/send.svg/" + palette.windowText + icon.width: Math.round(assistantQuestionInput.height * 0.5) + icon.height: Math.round(assistantQuestionInput.height * 0.5) + display: AbstractButton.IconOnly + focusPolicy: Qt.StrongFocus + + onClicked: assistantInputContainer.submitQuestion() + + Accessible.role: Accessible.Button + Accessible.name: qsTr("Send assistant question") + Accessible.onPressAction: assistantInputContainer.submitQuestion() + } + Button { id: assistantResetButton Layout.alignment: Qt.AlignVCenter @@ -343,17 +374,17 @@ ApplicationWindow { Layout.maximumWidth: assistantQuestionInput.height Layout.rightMargin: Style.trayHorizontalMargin padding: 0 - icon.source: "image://svgimage-custom-color/reply.svg/" + palette.windowText + icon.source: "image://svgimage-custom-color/add.svg/" + palette.windowText icon.width: Math.round(assistantQuestionInput.height * 0.5) icon.height: Math.round(assistantQuestionInput.height * 0.5) display: AbstractButton.IconOnly focusPolicy: Qt.StrongFocus - onClicked: assistantInputContainer.resetAssistantConversation() + onClicked: assistantResetConfirmationDialog.open() Accessible.role: Accessible.Button Accessible.name: qsTr("Start a new assistant chat") - Accessible.onPressAction: assistantInputContainer.resetAssistantConversation() + Accessible.onPressAction: assistantResetConfirmationDialog.open() } } @@ -712,3 +743,4 @@ ApplicationWindow { + From d466b5d86c4bedd59abee158b7c07d4c031ba833 Mon Sep 17 00:00:00 2001 From: Rello Date: Wed, 11 Feb 2026 07:19:50 +0100 Subject: [PATCH 34/42] better reset dialog Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 501edda3fb209..60e46c51e33ea 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -319,14 +319,23 @@ ApplicationWindow { anchors.bottomMargin: Style.trayHorizontalMargin spacing: Style.extraSmallSpacing - NativeDialogs.MessageDialog { + Dialog { id: assistantResetConfirmationDialog + modal: true + focus: true title: Systray.windowTitle - text: qsTr("Start new chat? This will clear the existing conversation") - buttons: NativeDialogs.MessageDialog.Ok | NativeDialogs.MessageDialog.Cancel + x: (trayWindow.width - width) / 2 + y: (trayWindow.height - height) / 2 + standardButtons: Dialog.Ok | Dialog.Cancel onAccepted: assistantInputContainer.resetAssistantConversation() + + Label { + width: parent.width + text: qsTr("Start new chat? This will clear the existing conversation") + wrapMode: Text.WordWrap + } } TextField { @@ -744,3 +753,4 @@ ApplicationWindow { + From 9d1fd47fe0b1e667749a2143f5a30ddabf43e6f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Bari?= Date: Wed, 11 Feb 2026 16:14:11 +0100 Subject: [PATCH 35/42] fix: Fixing the new conversation confirmation dialog geometry and style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tamás Bari --- src/gui/tray/MainWindow.qml | 104 ++++++++++++++++++++++++++++-------- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 60e46c51e33ea..fd7f3916724b0 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -292,6 +292,87 @@ ApplicationWindow { Keys.onEscapePressed: activateSearchFocus = false } + Dialog { + id: assistantResetConfirmationDialogWrapper + modal: true + focus: true + x: (trayWindow.width - width) / 2 + y: (trayWindow.height - height) / 2 + header: Item {} + footer: Item {} + onOpened: assistantResetConfirmationDialog.open() + + background: Rectangle { + border.width: 1 + border.color: "#808080" + radius: 10 + antialiasing: true + + layer.enabled: true + layer.smooth: true + layer.effect: DropShadow { + horizontalOffset: 4 + verticalOffset: 4 + radius: 10 + samples: 16 + color: "#80000000" + } + } + contentItem: Rectangle { + id: assistantResetConfirmationDialogContentRect + property int margin: 6 + + implicitWidth: assistantResetConfirmationDialog.implicitWidth + 2 * margin + implicitHeight: assistantResetConfirmationDialog.implicitHeight + 2 * margin + width: implicitWidth + height: implicitHeight + + Dialog { + id: assistantResetConfirmationDialog + + modal: false + focus: true + title: qsTr("Start new conversation?") + x: assistantResetConfirmationDialogContentRect.margin + y: assistantResetConfirmationDialogContentRect.margin + + footer: Row { + spacing: 6 + layoutDirection: Qt.RightToLeft + Button { + text: qsTr("New conversation") + onClicked: assistantResetConfirmationDialog.accept() + } + Button { + text: qsTr("Cancel") + onClicked: assistantResetConfirmationDialog.reject() + } + } + + onAccepted: { + assistantResetConfirmationDialogWrapper.close() + assistantInputContainer.resetAssistantConversation() + } + + onRejected: { + assistantResetConfirmationDialogWrapper.close() + } + + onDiscarded: { + assistantResetConfirmationDialogWrapper.close() + } + + Label { + id: assistantResetConfirmationDialogLabel + width: parent.width + anchors.centerIn: parent + text: qsTr("This will clear the existing conversation") + wrapMode: Text.WordWrap + } + } + } + } + RowLayout { id: assistantInputContainer visible: trayWindowMainItem.showAssistantPanel @@ -319,25 +400,6 @@ ApplicationWindow { anchors.bottomMargin: Style.trayHorizontalMargin spacing: Style.extraSmallSpacing - Dialog { - id: assistantResetConfirmationDialog - - modal: true - focus: true - title: Systray.windowTitle - x: (trayWindow.width - width) / 2 - y: (trayWindow.height - height) / 2 - standardButtons: Dialog.Ok | Dialog.Cancel - - onAccepted: assistantInputContainer.resetAssistantConversation() - - Label { - width: parent.width - text: qsTr("Start new chat? This will clear the existing conversation") - wrapMode: Text.WordWrap - } - } - TextField { id: assistantQuestionInput Layout.fillWidth: true @@ -389,11 +451,11 @@ ApplicationWindow { display: AbstractButton.IconOnly focusPolicy: Qt.StrongFocus - onClicked: assistantResetConfirmationDialog.open() + onClicked: assistantResetConfirmationDialogWrapper.open() Accessible.role: Accessible.Button Accessible.name: qsTr("Start a new assistant chat") - Accessible.onPressAction: assistantResetConfirmationDialog.open() + Accessible.onPressAction: assistantResetConfirmationDialogWrapper.open() } } From 07c17cb86b84ecb30c4fb5bdf3175b889d7220bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Bari?= Date: Thu, 12 Feb 2026 09:11:00 +0100 Subject: [PATCH 36/42] fix: Removing dim effect from modal Dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tamás Bari --- src/gui/tray/MainWindow.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index fd7f3916724b0..62dfe73af3047 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -296,6 +296,7 @@ ApplicationWindow { id: assistantResetConfirmationDialogWrapper modal: true focus: true + dim: false x: (trayWindow.width - width) / 2 y: (trayWindow.height - height) / 2 header: Item {} From e75c791442f3030ff1a8aa3bcb2fb288d4a3b0ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Bari?= Date: Thu, 12 Feb 2026 13:22:09 +0100 Subject: [PATCH 37/42] fix: fixing dialog background color + re-add dimming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tamás Bari --- src/gui/tray/MainWindow.qml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 62dfe73af3047..e7bcac1012488 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -296,7 +296,6 @@ ApplicationWindow { id: assistantResetConfirmationDialogWrapper modal: true focus: true - dim: false x: (trayWindow.width - width) / 2 y: (trayWindow.height - height) / 2 header: Item {} @@ -304,6 +303,7 @@ ApplicationWindow { onOpened: assistantResetConfirmationDialog.open() background: Rectangle { + color: palette.base border.width: 1 border.color: "#808080" radius: 10 @@ -327,6 +327,8 @@ ApplicationWindow { implicitHeight: assistantResetConfirmationDialog.implicitHeight + 2 * margin width: implicitWidth height: implicitHeight + border.color: "transparent" + color: "transparent" Dialog { id: assistantResetConfirmationDialog @@ -337,6 +339,11 @@ ApplicationWindow { x: assistantResetConfirmationDialogContentRect.margin y: assistantResetConfirmationDialogContentRect.margin + background: Rectangle { + border.color: "transparent" + color: "transparent" + } + footer: Row { spacing: 6 layoutDirection: Qt.RightToLeft From 4c63c3c907e934f06d969bf9becaaf41b5bb2a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Bari?= Date: Fri, 13 Feb 2026 10:21:23 +0100 Subject: [PATCH 38/42] fix: Removing header background color from dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tamás Bari --- src/gui/tray/MainWindow.qml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index e7bcac1012488..d84db31936692 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -344,6 +344,12 @@ ApplicationWindow { color: "transparent" } + header: Label { + id: titleLabel + text: assistantResetConfirmationDialog.title + font.weight: Font.Bold + } + footer: Row { spacing: 6 layoutDirection: Qt.RightToLeft @@ -376,6 +382,8 @@ ApplicationWindow { anchors.centerIn: parent text: qsTr("This will clear the existing conversation") wrapMode: Text.WordWrap + bottomPadding: 10 + topPadding: 10 } } } From 8b6299746e809e017e23f0723d600800383ffb93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Bari?= Date: Mon, 16 Feb 2026 14:51:25 +0100 Subject: [PATCH 39/42] fix: Fixing reset dialog aligment issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tamás Bari --- src/gui/tray/MainWindow.qml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index d84db31936692..b0fab457ff726 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -329,6 +329,7 @@ ApplicationWindow { height: implicitHeight border.color: "transparent" color: "transparent" + // color: "#ff0000" Dialog { id: assistantResetConfirmationDialog @@ -347,6 +348,7 @@ ApplicationWindow { header: Label { id: titleLabel text: assistantResetConfirmationDialog.title + leftPadding: 0 font.weight: Font.Bold } @@ -376,14 +378,17 @@ ApplicationWindow { assistantResetConfirmationDialogWrapper.close() } - Label { + contentItem: Label { id: assistantResetConfirmationDialogLabel - width: parent.width - anchors.centerIn: parent - text: qsTr("This will clear the existing conversation") + anchors.fill: parent + text: qsTr("This will clear the existing conversation.") wrapMode: Text.WordWrap bottomPadding: 10 topPadding: 10 + leftPadding: 0 + rightPadding: 0 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter } } } From 1358258c63f5a0db5853657ba35c65dff0030e9b Mon Sep 17 00:00:00 2001 From: Rello Date: Mon, 16 Feb 2026 19:11:26 +0100 Subject: [PATCH 40/42] Update MainWindow.qml Signed-off-by: Rello --- src/gui/tray/MainWindow.qml | 661 ++++-------------------------------- 1 file changed, 69 insertions(+), 592 deletions(-) diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 109cc9bf5fb29..0dfcc1f9cc7df 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -1,4 +1,3 @@ -<<<<<<< feature/AiIntegration /* * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-2.0-or-later @@ -271,6 +270,75 @@ ApplicationWindow { } } + Button { + id: trayWindowSyncWarning + + readonly property color warningIconColor: Style.errorBoxBackgroundColor + + anchors.top: trayWindowHeader.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.topMargin: Style.trayHorizontalMargin + anchors.leftMargin: Style.trayHorizontalMargin + anchors.rightMargin: Style.trayHorizontalMargin + + visible: UserModel.hasSyncErrors + && !(UserModel.syncErrorUserCount === 1 + && UserModel.firstSyncErrorUserId === UserModel.currentUserId) + padding: 0 + background: Rectangle { + radius: Style.slightlyRoundedButtonRadius + color: Qt.rgba(trayWindowSyncWarning.warningIconColor.r, + trayWindowSyncWarning.warningIconColor.g, + trayWindowSyncWarning.warningIconColor.b, + 0.2) + border.width: Style.normalBorderWidth + border.color: Qt.rgba(trayWindowSyncWarning.warningIconColor.r, + trayWindowSyncWarning.warningIconColor.g, + trayWindowSyncWarning.warningIconColor.b, + 0.6) + } + + Accessible.name: syncWarningText.text + Accessible.role: Accessible.Button + + contentItem: RowLayout { + anchors.fill: parent + spacing: 0 + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + Layout.topMargin: 4 + Layout.leftMargin: Style.trayHorizontalMargin + Layout.rightMargin: Style.trayHorizontalMargin + Layout.bottomMargin: 4 + + EnforcedPlainTextLabel { + id: syncWarningText + + Layout.fillWidth: true + font.pixelSize: Style.topLinePixelSize + font.bold: true + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + text: { + if (UserModel.syncErrorUserCount <= 1) { + return qsTr("Issue with account %1").arg(UserModel.firstSyncErrorUser ? UserModel.firstSyncErrorUser.name : ""); + } + return qsTr("Issues with several accounts"); + } + } + } + } + + onClicked: { + if (UserModel.firstSyncErrorUserId >= 0) { + UserModel.currentUserId = UserModel.firstSyncErrorUserId + } + } + } + UnifiedSearchInputContainer { id: trayWindowUnifiedSearchInputContainer visible: !trayWindowMainItem.showAssistantPanel @@ -823,594 +891,3 @@ ApplicationWindow { } } // Item trayWindowMainItem } - - - - - - - - - - - - - - - -======= -/* - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -import QtQml -import QtQuick -import QtQuick.Controls -import QtQuick.Window -import QtQuick.Layouts -import Qt5Compat.GraphicalEffects -import Qt.labs.platform as NativeDialogs - -import "../" -import "../filedetails/" - -// Custom qml modules are in /theme (and included by resources.qrc) -import Style - -import com.nextcloud.desktopclient - -ApplicationWindow { - id: trayWindow - - LayoutMirroring.enabled: Application.layoutDirection === Qt.RightToLeft - LayoutMirroring.childrenInherit: true - - title: Systray.windowTitle - // If the main dialog is displayed as a regular window we want it to be quadratic - width: Systray.useNormalWindow ? Style.trayWindowHeight : Style.trayWindowWidth - height: Style.trayWindowHeight - flags: Systray.useNormalWindow ? Qt.Window : Qt.Dialog | Qt.FramelessWindowHint - color: "transparent" - - readonly property int maxMenuHeight: Style.trayWindowHeight - Style.trayWindowHeaderHeight - 2 * Style.trayWindowBorderWidth - - Component.onCompleted: Systray.forceWindowInit(trayWindow) - - // Close tray window when focus is lost (e.g. click somewhere else on the screen) - onActiveChanged: { - if (!Systray.useNormalWindow && !active) { - hide(); - Systray.isOpen = false; - } - } - - onClosing: Systray.isOpen = false - - onVisibleChanged: { - // HACK: reload account Instantiator immediately by restting it - could be done better I guess - // see also id:trayWindowHeader.currentAccountHeaderButton.accountMenu below - trayWindowHeader.currentAccountHeaderButton.userLineInstantiator.active = false; - trayWindowHeader.currentAccountHeaderButton.userLineInstantiator.active = true; - syncStatus.model.load(); - } - - background: Rectangle { - radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - border.width: Style.trayWindowBorderWidth - border.color: palette.dark - color: palette.window - } - - Connections { - target: UserModel - function onCurrentUserChanged() { - trayWindowHeader.currentAccountHeaderButton.accountMenu.close(); - syncStatus.model.load(); - } - } - - Component { - id: errorMessageDialog - - NativeDialogs.MessageDialog { - id: dialog - - title: Systray.windowTitle - - onAccepted: destroy() - onRejected: destroy() - } - } - - Connections { - target: Systray - - function onIsOpenChanged() { - userStatusDrawer.close() - fileDetailsDrawer.close(); - - if (Systray.isOpen) { - trayWindowHeader.currentAccountHeaderButton.accountMenu.close(); - trayWindowHeader.appsMenu.close(); - trayWindowHeader.openLocalFolderButton.closeMenu() - UserModel.refreshSyncErrorUsers() - } - } - - function onShowErrorMessageDialog(error) { - var newErrorDialog = errorMessageDialog.createObject(trayWindow) - newErrorDialog.text = error - newErrorDialog.open() - } - - function onShowFileDetails(accountState, localPath, fileDetailsPage) { - fileDetailsDrawer.openFileDetails(accountState, localPath, fileDetailsPage); - } - } - - OpacityMask { - anchors.fill: parent - anchors.margins: Style.trayWindowBorderWidth - source: ShaderEffectSource { - sourceItem: trayWindowMainItem - hideSource: true - } - maskSource: Rectangle { - width: trayWindow.width - height: trayWindow.height - radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - } - } - - Drawer { - id: userStatusDrawer - width: parent.width - height: parent.height - Style.trayDrawerMargin - padding: 0 - edge: Qt.BottomEdge - modal: true - visible: false - - background: Rectangle { - radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - border.width: Style.trayWindowBorderWidth - border.color: palette.dark - color: Style.colorWithoutTransparency(palette.base) - } - - property int userIndex: 0 - property string modeSetStatus: "setStatus" - property string modeStatusMessage: "statusMessage" - property string initialMode: modeSetStatus - - function openUserStatusDrawer(index, mode) { - console.log(`About to show dialog for user with index ${index}`); - userIndex = index; - initialMode = mode ? mode : modeSetStatus; - open(); - } - - function openUserStatusMessageDrawer(index) { - openUserStatusDrawer(index, modeStatusMessage); - } - - Loader { - id: userStatusContents - anchors.fill: parent - active: userStatusDrawer.visible - sourceComponent: UserStatusSelectorPage { - anchors.fill: parent - userIndex: userStatusDrawer.userIndex - mode: userStatusDrawer.initialMode - onFinished: userStatusDrawer.close() - } - } - } - - Drawer { - id: fileDetailsDrawer - width: parent.width - Style.trayDrawerMargin - height: parent.height - padding: 0 - edge: Qt.RightEdge - modal: true - visible: false - clip: true - - background: Rectangle { - radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - border.width: Style.trayWindowBorderWidth - border.color: palette.dark - color: Style.colorWithoutTransparency(palette.base) - } - - property var folderAccountState: ({}) - property string fileLocalPath: "" - property var pageToShow: Systray.FileDetailsPage.Activity - - function openFileDetails(accountState, localPath, fileDetailsPage) { - console.log(`About to show file details view in tray for ${localPath}`); - folderAccountState = accountState; - fileLocalPath = localPath; - pageToShow = fileDetailsPage; - - if(!opened) { - open(); - } - } - - Loader { - id: fileDetailsContents - anchors.fill: parent - active: fileDetailsDrawer.visible - onActiveChanged: { - if (active) { - Systray.showFileDetailsPage(fileDetailsDrawer.fileLocalPath, - fileDetailsDrawer.pageToShow); - } - } - sourceComponent: FileDetailsView { - id: fileDetails - - width: parent.width - height: parent.height - - backgroundsVisible: false - accentColor: Style.accentColor - accountState: fileDetailsDrawer.folderAccountState - localPath: fileDetailsDrawer.fileLocalPath - showCloseButton: true - - onCloseButtonClicked: fileDetailsDrawer.close() - } - } - } - - Rectangle { - id: trayWindowMainItem - - property bool isUnifiedSearchActive: unifiedSearchResultsListViewSkeletonLoader.active - || unifiedSearchResultNothingFound.visible - || unifiedSearchResultsErrorLabel.visible - || unifiedSearchResultsListView.visible - || trayWindowUnifiedSearchInputContainer.activateSearchFocus - - anchors.fill: parent - anchors.margins: Style.trayWindowBorderWidth - clip: true - - radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - color: Style.colorWithoutTransparency(palette.base) - - Accessible.role: Accessible.Grouping - Accessible.name: qsTr("Main content") - - MouseArea { - anchors.fill: parent - onClicked: forceActiveFocus() - } - - TrayWindowHeader { - id: trayWindowHeader - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - height: Style.trayWindowHeaderHeight - } - - Button { - id: trayWindowSyncWarning - - readonly property color warningIconColor: Style.errorBoxBackgroundColor - - anchors.top: trayWindowHeader.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.topMargin: Style.trayHorizontalMargin - anchors.leftMargin: Style.trayHorizontalMargin - anchors.rightMargin: Style.trayHorizontalMargin - - visible: UserModel.hasSyncErrors - && !(UserModel.syncErrorUserCount === 1 - && UserModel.firstSyncErrorUserId === UserModel.currentUserId) - padding: 0 - background: Rectangle { - radius: Style.slightlyRoundedButtonRadius - color: Qt.rgba(trayWindowSyncWarning.warningIconColor.r, - trayWindowSyncWarning.warningIconColor.g, - trayWindowSyncWarning.warningIconColor.b, - 0.2) - border.width: Style.normalBorderWidth - border.color: Qt.rgba(trayWindowSyncWarning.warningIconColor.r, - trayWindowSyncWarning.warningIconColor.g, - trayWindowSyncWarning.warningIconColor.b, - 0.6) - } - - Accessible.name: syncWarningText.text - Accessible.role: Accessible.Button - - contentItem: RowLayout { - anchors.fill: parent - spacing: 0 - - ColumnLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - Layout.topMargin: 4 - Layout.leftMargin: Style.trayHorizontalMargin - Layout.rightMargin: Style.trayHorizontalMargin - Layout.bottomMargin: 4 - - EnforcedPlainTextLabel { - id: syncWarningText - - Layout.fillWidth: true - font.pixelSize: Style.topLinePixelSize - font.bold: true - wrapMode: Text.WordWrap - horizontalAlignment: Text.AlignHCenter - text: { - if (UserModel.syncErrorUserCount <= 1) { - return qsTr("Issue with account %1").arg(UserModel.firstSyncErrorUser ? UserModel.firstSyncErrorUser.name : ""); - } - return qsTr("Issues with several accounts"); - } - } - } - } - - onClicked: { - if (UserModel.firstSyncErrorUserId >= 0) { - UserModel.currentUserId = UserModel.firstSyncErrorUserId - } - } - } - - UnifiedSearchInputContainer { - id: trayWindowUnifiedSearchInputContainer - - property bool activateSearchFocus: activeFocus - - anchors.top: trayWindowSyncWarning.visible - ? trayWindowSyncWarning.bottom - : trayWindowHeader.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.topMargin: Style.trayHorizontalMargin - anchors.leftMargin: Style.trayHorizontalMargin - anchors.rightMargin: Style.trayHorizontalMargin - - text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm - readOnly: !UserModel.currentUser.isConnected || UserModel.currentUser.unifiedSearchResultsListModel.currentFetchMoreInProgressProviderId - isSearchInProgress: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress - onTextEdited: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = trayWindowUnifiedSearchInputContainer.text } - onClearText: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = "" } - onActiveFocusChanged: activateSearchFocus = activeFocus && focusReason !== Qt.TabFocusReason && focusReason !== Qt.BacktabFocusReason - Keys.onEscapePressed: activateSearchFocus = false - } - - Rectangle { - id: bottomUnifiedSearchInputSeparator - - anchors.top: trayWindowUnifiedSearchInputContainer.bottom - anchors.left: parent.left - anchors.right: parent.right - anchors.topMargin: Style.trayHorizontalMargin - - height: 1 - color: palette.dark - visible: trayWindowMainItem.isUnifiedSearchActive - } - - ErrorBox { - id: unifiedSearchResultsErrorLabel - visible: UserModel.currentUser.unifiedSearchResultsListModel.errorString && !unifiedSearchResultsListView.visible && ! UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress && ! UserModel.currentUser.unifiedSearchResultsListModel.currentFetchMoreInProgressProviderId - text: UserModel.currentUser.unifiedSearchResultsListModel.errorString - anchors.top: bottomUnifiedSearchInputSeparator.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.margins: Style.trayHorizontalMargin - } - - UnifiedSearchPlaceholderView { - id: unifiedSearchPlaceholderView - - anchors.top: bottomUnifiedSearchInputSeparator.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.bottom: trayWindowMainItem.bottom - anchors.topMargin: Style.trayHorizontalMargin - - visible: trayWindowUnifiedSearchInputContainer.activateSearchFocus && !UserModel.currentUser.unifiedSearchResultsListModel.searchTerm - } - - UnifiedSearchResultNothingFound { - id: unifiedSearchResultNothingFound - - anchors.top: bottomUnifiedSearchInputSeparator.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.topMargin: Style.trayHorizontalMargin - - text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm - - property bool isSearchRunning: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress - property bool waitingForSearchTermEditEnd: UserModel.currentUser.unifiedSearchResultsListModel.waitingForSearchTermEditEnd - property bool isSearchResultsEmpty: unifiedSearchResultsListView.count === 0 - property bool nothingFound: text && isSearchResultsEmpty && !UserModel.currentUser.unifiedSearchResultsListModel.errorString - - visible: !isSearchRunning && !waitingForSearchTermEditEnd && nothingFound - } - - Loader { - id: unifiedSearchResultsListViewSkeletonLoader - - anchors.top: bottomUnifiedSearchInputSeparator.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.bottom: trayWindowMainItem.bottom - anchors.margins: controlRoot.padding - - active: !unifiedSearchResultNothingFound.visible && - !unifiedSearchResultsListView.visible && - !UserModel.currentUser.unifiedSearchResultsListModel.errorString && - UserModel.currentUser.unifiedSearchResultsListModel.searchTerm - - sourceComponent: UnifiedSearchResultItemSkeletonContainer { - anchors.fill: parent - spacing: unifiedSearchResultsListView.spacing - animationRectangleWidth: trayWindow.width - } - } - - ScrollView { - id: controlRoot - contentWidth: availableWidth - - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - - data: WheelHandler { - target: controlRoot.contentItem - } - visible: unifiedSearchResultsListView.count > 0 - - anchors.top: bottomUnifiedSearchInputSeparator.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.bottom: trayWindowMainItem.bottom - - ListView { - id: unifiedSearchResultsListView - spacing: 4 - clip: true - - keyNavigationEnabled: true - - reuseItems: true - - Accessible.role: Accessible.List - Accessible.name: qsTr("Unified search results list") - - model: UserModel.currentUser.unifiedSearchResultsListModel - - delegate: UnifiedSearchResultListItem { - width: unifiedSearchResultsListView.width - isSearchInProgress: unifiedSearchResultsListView.model.isSearchInProgress - currentFetchMoreInProgressProviderId: unifiedSearchResultsListView.model.currentFetchMoreInProgressProviderId - fetchMoreTriggerClicked: unifiedSearchResultsListView.model.fetchMoreTriggerClicked - resultClicked: unifiedSearchResultsListView.model.resultClicked - ListView.onPooled: isPooled = true - ListView.onReused: isPooled = false - } - - section.property: "providerName" - section.criteria: ViewSection.FullString - section.delegate: UnifiedSearchResultSectionItem { - width: unifiedSearchResultsListView.width - } - } - } - - SyncStatus { - id: syncStatus - - accentColor: Style.accentColor - visible: !trayWindowMainItem.isUnifiedSearchActive - - anchors.top: trayWindowUnifiedSearchInputContainer.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - } - - Rectangle { - id: syncStatusSeparator - anchors.left: syncStatus.left - anchors.right: syncStatus.right - anchors.bottom: syncStatus.bottom - height: 1 - color: palette.dark - visible: !trayWindowMainItem.isUnifiedSearchActive - } - - Loader { - id: newActivitiesButtonLoader - - anchors.top: activityList.top - anchors.topMargin: 5 - anchors.horizontalCenter: activityList.horizontalCenter - - width: Style.newActivitiesButtonWidth - height: Style.newActivitiesButtonHeight - - z: 1 - - active: false - - sourceComponent: Button { - id: newActivitiesButton - hoverEnabled: true - padding: Style.smallSpacing - - anchors.fill: parent - - text: qsTr("New activities") - - icon.source: "image://svgimage-custom-color/expand-less-black.svg" + "/" + Style.currentUserHeaderTextColor - icon.width: Style.activityLabelBaseWidth - icon.height: Style.activityLabelBaseWidth - - onClicked: { - activityList.scrollToTop(); - newActivitiesButtonLoader.active = false - } - - Timer { - id: newActivitiesButtonDisappearTimer - interval: Style.newActivityButtonDisappearTimeout - running: newActivitiesButtonLoader.active && !newActivitiesButton.hovered - repeat: false - onTriggered: fadeoutActivitiesButtonDisappear.running = true - } - - OpacityAnimator { - id: fadeoutActivitiesButtonDisappear - target: newActivitiesButton - from: 1 - to: 0 - duration: Style.newActivityButtonDisappearFadeTimeout - loops: 1 - running: false - onFinished: newActivitiesButtonLoader.active = false - } - } - } - - ActivityList { - id: activityList - visible: !trayWindowMainItem.isUnifiedSearchActive - anchors.top: syncStatus.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.bottom: trayWindowMainItem.bottom - - activeFocusOnTab: true - model: activityModel - onOpenFile: Qt.openUrlExternally(filePath); - onActivityItemClicked: { - model.slotTriggerDefaultAction(index) - } - Connections { - target: activityModel - function onInteractiveActivityReceived() { - if (!activityList.atYBeginning) { - newActivitiesButtonLoader.active = true; - } - } - } - } - } // Item trayWindowMainItem -} ->>>>>>> master From 8a8fa396d471634c3fe3cbb88e463f964b164fbc Mon Sep 17 00:00:00 2001 From: Rello Date: Mon, 16 Feb 2026 19:13:26 +0100 Subject: [PATCH 41/42] Update usermodel.cpp Signed-off-by: Rello --- src/gui/tray/usermodel.cpp | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 6f269ffda586b..4601380be0b9e 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -49,8 +49,6 @@ using namespace Qt::StringLiterals; // refreshes of the notifications #define NOTIFICATION_REQUEST_FREE_PERIOD 15000 -namespace OCC { - namespace { constexpr qint64 expiredActivitiesCheckIntervalMsecs = 1000 * 60; @@ -141,9 +139,6 @@ QString assistantOutputFromTask(const QJsonObject &task) return QString(); } -} - -======= struct SyncStatusInfo { QUrl icon; @@ -244,7 +239,6 @@ bool isSyncStatusError(const OCC::SyncResult::Status status) namespace OCC { ->>>>>>> master TrayFolderInfo::TrayFolderInfo(const QString &name, const QString &parentPath, const QString &fullPath, FolderType folderType) : _name(name) , _parentPath(parentPath) From 812b595c64a0d04734705b6fa2af950d058d997d Mon Sep 17 00:00:00 2001 From: Rello Date: Mon, 16 Feb 2026 20:43:46 +0100 Subject: [PATCH 42/42] Update usermodel.cpp Signed-off-by: Rello --- src/gui/tray/usermodel.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 4601380be0b9e..36a261940e8ea 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -53,7 +53,6 @@ namespace { constexpr qint64 expiredActivitiesCheckIntervalMsecs = 1000 * 60; constexpr qint64 activityDefaultExpirationTimeMsecs = 1000 * 60 * 10; -<<<<<<< feature/AiIntegration constexpr qint64 assistantPollIntervalMsecs = 2000; constexpr int assistantSuccessMinStatusCode = 200; constexpr int assistantSuccessMaxStatusCode = 300;