From ee1bf4ffe5cdf30e018106932abd197125e90cbb Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:25:30 +0200 Subject: [PATCH] feat: Improve chat, status and message sending keys (#361) --- ChatView/CMakeLists.txt | 1 + ChatView/ChatRootView.cpp | 58 +++++++++++ ChatView/ChatRootView.hpp | 4 + ChatView/icons/warning-icon.svg | 5 + ChatView/qml/RootItem.qml | 153 +++++++++++++++++++++++++--- ChatView/qml/controls/BottomBar.qml | 15 +++ UIControls/qml/QoAButton.qml | 18 +++- qodeassist.cpp | 2 +- 8 files changed, 238 insertions(+), 18 deletions(-) create mode 100644 ChatView/icons/warning-icon.svg diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index f759cdc..b913035 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -45,6 +45,7 @@ qt_add_qml_module(QodeAssistChatView icons/window-unlock.svg icons/chat-icon.svg icons/chat-pause-icon.svg + icons/warning-icon.svg icons/new-chat-icon.svg icons/rules-icon.svg icons/context-icon.svg diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index 689a055..140d20b 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -52,6 +53,21 @@ bool isChatEditor(Core::IEditor *editor) return editor && editor->document() && editor->document()->id() == Utils::Id(Constants::QODE_ASSIST_CHAT_EDITOR_ID); } + +QKeySequence sendMessageKeySequence() +{ + auto command = Core::ActionManager::command(Constants::QODE_ASSIST_CHAT_SEND_MESSAGE); + if (!command) + return {}; + + QKeySequence sequence = command->keySequence(); + if (sequence.isEmpty()) { + const QList defaults = command->defaultKeySequences(); + if (!defaults.isEmpty()) + sequence = defaults.constFirst(); + } + return sequence; +} } // namespace ChatRootView::ChatRootView(QQuickItem *parent) @@ -76,6 +92,22 @@ ChatRootView::ChatRootView(QQuickItem *parent) this, [this]() { setIsSyncOpenFiles(Settings::chatAssistantSettings().linkOpenFiles()); }); + QMetaObject::invokeMethod( + this, + [this] { + if (auto sendCommand + = Core::ActionManager::command(Constants::QODE_ASSIST_CHAT_SEND_MESSAGE)) { + connect( + sendCommand, + &Core::Command::keySequenceChanged, + this, + &ChatRootView::sendShortcutTextChanged, + Qt::UniqueConnection); + } + emit sendShortcutTextChanged(); + }, + Qt::QueuedConnection); + auto &settings = Settings::generalSettings(); connect( @@ -743,6 +775,32 @@ void ChatRootView::calculateMessageTokensCount(const QString &message) m_tokenCounter->setMessage(message); } +bool ChatRootView::isSendShortcut(int key, int modifiers) const +{ + const QKeySequence sequence = sendMessageKeySequence(); + if (sequence.isEmpty()) + return false; + + const QKeyCombination combination = sequence[0]; + const int sequenceKey = combination.key(); + + const int relevantMask = Qt::ShiftModifier | Qt::ControlModifier | Qt::AltModifier + | Qt::MetaModifier; + const int sequenceModifiers = combination.keyboardModifiers() & relevantMask; + const int eventModifiers = modifiers & relevantMask; + + const bool isReturnLike = sequenceKey == Qt::Key_Return || sequenceKey == Qt::Key_Enter; + const bool keyMatches = key == sequenceKey + || (isReturnLike && (key == Qt::Key_Return || key == Qt::Key_Enter)); + + return keyMatches && eventModifiers == sequenceModifiers; +} + +QString ChatRootView::sendShortcutText() const +{ + return sendMessageKeySequence().toString(QKeySequence::NativeText); +} + void ChatRootView::setIsSyncOpenFiles(bool state) { if (m_isSyncOpenFiles != state) { diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index bdf6ff2..f63f0b0 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -49,6 +49,7 @@ class ChatRootView : public QQuickItem Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL) Q_PROPERTY(bool useTools READ useTools WRITE setUseTools NOTIFY useToolsChanged FINAL) Q_PROPERTY(bool useThinking READ useThinking WRITE setUseThinking NOTIFY useThinkingChanged FINAL) + Q_PROPERTY(QString sendShortcutText READ sendShortcutText NOTIFY sendShortcutTextChanged FINAL) Q_PROPERTY(int currentMessageTotalEdits READ currentMessageTotalEdits NOTIFY currentMessageEditsStatsChanged FINAL) Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL) @@ -98,6 +99,8 @@ public: Q_INVOKABLE void showAddImageDialog(); Q_INVOKABLE bool isImageFile(const QString &filePath) const; Q_INVOKABLE void calculateMessageTokensCount(const QString &message); + Q_INVOKABLE bool isSendShortcut(int key, int modifiers) const; + QString sendShortcutText() const; Q_INVOKABLE void setIsSyncOpenFiles(bool state); Q_INVOKABLE void openChatHistoryFolder(); Q_INVOKABLE void openRulesFolder(); @@ -218,6 +221,7 @@ signals: void lastErrorMessageChanged(); void lastInfoMessageChanged(); + void sendShortcutTextChanged(); void activeRulesChanged(); void activeRulesCountChanged(); diff --git a/ChatView/icons/warning-icon.svg b/ChatView/icons/warning-icon.svg new file mode 100644 index 0000000..73a25ca --- /dev/null +++ b/ChatView/icons/warning-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index 46d0a0f..e812b12 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -19,6 +19,9 @@ ChatRootView { colorGroup: SystemPalette.Active } + property bool hasActiveError: false + readonly property color errorColor: "#d32f2f" + palette { window: sysPalette.window windowText: sysPalette.windowText @@ -411,11 +414,10 @@ ChatRootView { QQC.TextArea { id: messageInput - placeholderText: Qt.platform.os === "osx" - ? qsTr("Type your message here... (⌘+↩ to send)") - : qsTr("Type your message here... (Ctrl+Enter to send)") + placeholderText: qsTr("Type your message here... (%1 to send)").arg(root.sendShortcutText) placeholderTextColor: palette.mid color: palette.text + wrapMode: TextArea.Wrap background: Rectangle { radius: 2 color: palette.base @@ -494,6 +496,9 @@ ChatRootView { skillCommandPopup.dismiss() event.accepted = true } + } else if (root.isSendShortcut(event.key, event.modifiers)) { + root.sendChatMessage() + event.accepted = true } } @@ -586,13 +591,21 @@ ChatRootView { Layout.preferredHeight: 40 isCompressing: root.isCompressing + isProcessing: root.isRequestInProgress sendButton.onClicked: !root.isRequestInProgress ? root.sendChatMessage() : root.cancelRequest() - sendButton.icon.source: !root.isRequestInProgress ? "qrc:/qt/qml/ChatView/icons/chat-icon.svg" - : "qrc:/qt/qml/ChatView/icons/chat-pause-icon.svg" - sendButton.text: !root.isRequestInProgress ? qsTr("Send") : qsTr("Stop") - sendButtonTooltip.text: !root.isRequestInProgress ? qsTr("Send message to LLM %1").arg(Qt.platform.os === "osx" ? "Cmd+Return" : "Ctrl+Return") - : qsTr("Stop") + sendButton.icon.source: root.isRequestInProgress + ? "" + : (root.hasActiveError ? "qrc:/qt/qml/ChatView/icons/warning-icon.svg" + : "qrc:/qt/qml/ChatView/icons/chat-icon.svg") + sendButton.text: root.isRequestInProgress ? qsTr("Stop") : qsTr("Send") + sendButton.accentColor: (root.hasActiveError && !root.isRequestInProgress) + ? root.errorColor : "transparent" + sendButtonTooltip.text: root.isRequestInProgress + ? qsTr("Stop") + : (root.hasActiveError + ? root.lastErrorMessage + : qsTr("Send message to LLM %1").arg(root.sendShortcutText)) compressButton.onClicked: compressConfirmDialog.open() cancelCompressButton.onClicked: root.cancelCompression() syncOpenFiles { @@ -667,6 +680,7 @@ ChatRootView { } function sendChatMessage() { + root.hasActiveError = false root.sendMessage(fileMentionPopup.expandMentions(messageInput.text)) messageInput.text = "" fileMentionPopup.clearMentions() @@ -689,13 +703,122 @@ ChatRootView { onAccepted: root.compressCurrentChat() } - Toast { - id: errorToast - z: 1000 + Rectangle { + id: errorBanner - color: Qt.rgba(0.8, 0.2, 0.2, 0.9) - border.color: Qt.darker(infoToast.color, 1.3) - toastTextColor: "#FFFFFF" + z: 1000 + visible: root.hasActiveError && root.lastErrorMessage.length > 0 + + width: parent.width / 2 + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: 10 + anchors.bottomMargin: bottomBar.height + 48 + + height: visible ? errorRow.implicitHeight + 12 : 0 + + color: Qt.rgba(0.83, 0.18, 0.18, 0.96) + radius: 6 + border.color: Qt.darker(color, 1.3) + border.width: 1 + + RowLayout { + id: errorRow + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 6 + spacing: 8 + + TextEdit { + Layout.fillWidth: true + text: root.lastErrorMessage + color: "#FFFFFF" + font.pixelSize: 12 + wrapMode: TextEdit.Wrap + readOnly: true + selectByMouse: true + selectionColor: Qt.darker(errorBanner.color, 1.3) + } + + Rectangle { + id: copyErrorButton + + property bool copied: false + + Layout.alignment: Qt.AlignTop + implicitWidth: copyErrorLabel.implicitWidth + 18 + implicitHeight: 22 + radius: 4 + color: copyErrorMouse.containsMouse ? Qt.rgba(1, 1, 1, 0.28) + : Qt.rgba(1, 1, 1, 0.16) + border.color: Qt.rgba(1, 1, 1, 0.45) + border.width: 1 + + Behavior on color { ColorAnimation { duration: 120 } } + + Text { + id: copyErrorLabel + + anchors.centerIn: parent + text: copyErrorButton.copied ? qsTr("Copied") : qsTr("Copy") + color: "#FFFFFF" + font.pixelSize: 12 + } + + MouseArea { + id: copyErrorMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.copyToClipboard(root.lastErrorMessage) + copyErrorButton.copied = true + copyErrorResetTimer.restart() + } + } + + Timer { + id: copyErrorResetTimer + + interval: 1500 + onTriggered: copyErrorButton.copied = false + } + } + + Rectangle { + id: closeErrorButton + + Layout.alignment: Qt.AlignTop + implicitWidth: 22 + implicitHeight: 22 + radius: 4 + color: closeErrorMouse.containsMouse ? Qt.rgba(1, 1, 1, 0.28) : "transparent" + border.color: Qt.rgba(1, 1, 1, 0.45) + border.width: closeErrorMouse.containsMouse ? 1 : 0 + + Behavior on color { ColorAnimation { duration: 120 } } + + Text { + anchors.centerIn: parent + text: "✕" + color: "#FFFFFF" + font.pixelSize: 12 + } + + MouseArea { + id: closeErrorMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.hasActiveError = false + } + } + } } Toast { @@ -735,7 +858,7 @@ ChatRootView { target: root function onLastErrorMessageChanged() { if (root.lastErrorMessage.length > 0) { - errorToast.show(root.lastErrorMessage) + root.hasActiveError = true } } function onLastInfoMessageChanged() { diff --git a/ChatView/qml/controls/BottomBar.qml b/ChatView/qml/controls/BottomBar.qml index 583077d..c368dc1 100644 --- a/ChatView/qml/controls/BottomBar.qml +++ b/ChatView/qml/controls/BottomBar.qml @@ -19,6 +19,7 @@ Rectangle { property alias cancelCompressButton: cancelCompressButtonId property bool isCompressing: false + property bool isProcessing: false property alias sendButtonTooltip: sendButtonTooltipId color: palette.window.hslLightness > 0.5 ? @@ -159,11 +160,25 @@ Rectangle { QoAButton { id: sendButtonId + leftPadding: root.isProcessing ? 22 : 4 + icon { height: 15 width: 15 } + BusyIndicator { + id: sendBusyIndicator + + anchors.left: parent.left + anchors.leftMargin: 5 + anchors.verticalCenter: parent.verticalCenter + width: 14 + height: 14 + running: root.isProcessing + visible: root.isProcessing + } + QoAToolTip { id: sendButtonTooltipId diff --git a/UIControls/qml/QoAButton.qml b/UIControls/qml/QoAButton.qml index 973f1aa..72b6eb5 100644 --- a/UIControls/qml/QoAButton.qml +++ b/UIControls/qml/QoAButton.qml @@ -7,6 +7,8 @@ import QtQuick.Controls.Basic Button { id: control + property color accentColor: "transparent" + focusPolicy: Qt.NoFocus padding: 4 @@ -18,11 +20,15 @@ Button { background: Rectangle { id: bg + readonly property bool hasAccent: control.accentColor.a > 0 + implicitHeight: 20 color: !control.enabled || !control.down ? control.palette.button : control.palette.dark - border.color: !control.enabled || (!control.hovered && !control.visualFocus) ? control.palette.mid : control.palette.highlight - border.width: 1 + border.color: bg.hasAccent + ? control.accentColor + : (!control.enabled || (!control.hovered && !control.visualFocus) ? control.palette.mid : control.palette.highlight) + border.width: bg.hasAccent ? 2 : 1 radius: 4 Rectangle { @@ -35,5 +41,13 @@ Button { opacity: control.hovered ? 0.3 : 0.01 Behavior on opacity {NumberAnimation{duration: 250}} } + + Rectangle { + anchors.fill: bg + radius: bg.radius + color: control.accentColor + visible: bg.hasAccent + opacity: 0.15 + } } } diff --git a/qodeassist.cpp b/qodeassist.cpp index 3f1a6ae..e9da4b1 100644 --- a/qodeassist.cpp +++ b/qodeassist.cpp @@ -309,7 +309,7 @@ public: sendMessageAction.setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT)); sendMessageAction.setText(Tr::tr("Send QodeAssist Chat Message")); sendMessageAction.setToolTip(Tr::tr("Send the current message to the LLM")); - sendMessageAction.setDefaultKeySequence(QKeySequence(Qt::CTRL | Qt::Key_Return)); + sendMessageAction.setDefaultKeySequence(QKeySequence(Qt::Key_Return)); sendMessageAction.addOnTriggered(this, [] { if (auto chatWidget = Chat::ChatWidget::focusedInstance()) chatWidget->sendMessage();