feat: Improve chat, status and message sending keys (#361)

This commit is contained in:
Petr Mironychev
2026-06-06 11:25:30 +02:00
committed by GitHub
parent aaca9e2a0b
commit ee1bf4ffe5
8 changed files with 238 additions and 18 deletions

View File

@@ -45,6 +45,7 @@ qt_add_qml_module(QodeAssistChatView
icons/window-unlock.svg icons/window-unlock.svg
icons/chat-icon.svg icons/chat-icon.svg
icons/chat-pause-icon.svg icons/chat-pause-icon.svg
icons/warning-icon.svg
icons/new-chat-icon.svg icons/new-chat-icon.svg
icons/rules-icon.svg icons/rules-icon.svg
icons/context-icon.svg icons/context-icon.svg

View File

@@ -10,6 +10,7 @@
#include <QFile> #include <QFile>
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo> #include <QFileInfo>
#include <QKeySequence>
#include <QMessageBox> #include <QMessageBox>
#include <QQmlContext> #include <QQmlContext>
#include <QQmlEngine> #include <QQmlEngine>
@@ -52,6 +53,21 @@ bool isChatEditor(Core::IEditor *editor)
return editor && editor->document() return editor && editor->document()
&& editor->document()->id() == Utils::Id(Constants::QODE_ASSIST_CHAT_EDITOR_ID); && 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<QKeySequence> defaults = command->defaultKeySequences();
if (!defaults.isEmpty())
sequence = defaults.constFirst();
}
return sequence;
}
} // namespace } // namespace
ChatRootView::ChatRootView(QQuickItem *parent) ChatRootView::ChatRootView(QQuickItem *parent)
@@ -76,6 +92,22 @@ ChatRootView::ChatRootView(QQuickItem *parent)
this, this,
[this]() { setIsSyncOpenFiles(Settings::chatAssistantSettings().linkOpenFiles()); }); [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(); auto &settings = Settings::generalSettings();
connect( connect(
@@ -743,6 +775,32 @@ void ChatRootView::calculateMessageTokensCount(const QString &message)
m_tokenCounter->setMessage(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) void ChatRootView::setIsSyncOpenFiles(bool state)
{ {
if (m_isSyncOpenFiles != state) { if (m_isSyncOpenFiles != state) {

View File

@@ -49,6 +49,7 @@ class ChatRootView : public QQuickItem
Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL) Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL)
Q_PROPERTY(bool useTools READ useTools WRITE setUseTools NOTIFY useToolsChanged 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(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 currentMessageTotalEdits READ currentMessageTotalEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL) Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
@@ -98,6 +99,8 @@ public:
Q_INVOKABLE void showAddImageDialog(); Q_INVOKABLE void showAddImageDialog();
Q_INVOKABLE bool isImageFile(const QString &filePath) const; Q_INVOKABLE bool isImageFile(const QString &filePath) const;
Q_INVOKABLE void calculateMessageTokensCount(const QString &message); 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 setIsSyncOpenFiles(bool state);
Q_INVOKABLE void openChatHistoryFolder(); Q_INVOKABLE void openChatHistoryFolder();
Q_INVOKABLE void openRulesFolder(); Q_INVOKABLE void openRulesFolder();
@@ -218,6 +221,7 @@ signals:
void lastErrorMessageChanged(); void lastErrorMessageChanged();
void lastInfoMessageChanged(); void lastInfoMessageChanged();
void sendShortcutTextChanged();
void activeRulesChanged(); void activeRulesChanged();
void activeRulesCountChanged(); void activeRulesCountChanged();

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3L22 20H2L12 3Z" stroke="black" stroke-width="2" stroke-linejoin="round"/>
<path d="M12 10V14" stroke="black" stroke-width="2" stroke-linecap="round"/>
<path d="M12 17H12.01" stroke="black" stroke-width="2.4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

View File

@@ -19,6 +19,9 @@ ChatRootView {
colorGroup: SystemPalette.Active colorGroup: SystemPalette.Active
} }
property bool hasActiveError: false
readonly property color errorColor: "#d32f2f"
palette { palette {
window: sysPalette.window window: sysPalette.window
windowText: sysPalette.windowText windowText: sysPalette.windowText
@@ -411,11 +414,10 @@ ChatRootView {
QQC.TextArea { QQC.TextArea {
id: messageInput id: messageInput
placeholderText: Qt.platform.os === "osx" placeholderText: qsTr("Type your message here... (%1 to send)").arg(root.sendShortcutText)
? qsTr("Type your message here... (⌘+↩ to send)")
: qsTr("Type your message here... (Ctrl+Enter to send)")
placeholderTextColor: palette.mid placeholderTextColor: palette.mid
color: palette.text color: palette.text
wrapMode: TextArea.Wrap
background: Rectangle { background: Rectangle {
radius: 2 radius: 2
color: palette.base color: palette.base
@@ -494,6 +496,9 @@ ChatRootView {
skillCommandPopup.dismiss() skillCommandPopup.dismiss()
event.accepted = true event.accepted = true
} }
} else if (root.isSendShortcut(event.key, event.modifiers)) {
root.sendChatMessage()
event.accepted = true
} }
} }
@@ -586,13 +591,21 @@ ChatRootView {
Layout.preferredHeight: 40 Layout.preferredHeight: 40
isCompressing: root.isCompressing isCompressing: root.isCompressing
isProcessing: root.isRequestInProgress
sendButton.onClicked: !root.isRequestInProgress ? root.sendChatMessage() sendButton.onClicked: !root.isRequestInProgress ? root.sendChatMessage()
: root.cancelRequest() : root.cancelRequest()
sendButton.icon.source: !root.isRequestInProgress ? "qrc:/qt/qml/ChatView/icons/chat-icon.svg" sendButton.icon.source: root.isRequestInProgress
: "qrc:/qt/qml/ChatView/icons/chat-pause-icon.svg" ? ""
sendButton.text: !root.isRequestInProgress ? qsTr("Send") : qsTr("Stop") : (root.hasActiveError ? "qrc:/qt/qml/ChatView/icons/warning-icon.svg"
sendButtonTooltip.text: !root.isRequestInProgress ? qsTr("Send message to LLM %1").arg(Qt.platform.os === "osx" ? "Cmd+Return" : "Ctrl+Return") : "qrc:/qt/qml/ChatView/icons/chat-icon.svg")
: qsTr("Stop") 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() compressButton.onClicked: compressConfirmDialog.open()
cancelCompressButton.onClicked: root.cancelCompression() cancelCompressButton.onClicked: root.cancelCompression()
syncOpenFiles { syncOpenFiles {
@@ -667,6 +680,7 @@ ChatRootView {
} }
function sendChatMessage() { function sendChatMessage() {
root.hasActiveError = false
root.sendMessage(fileMentionPopup.expandMentions(messageInput.text)) root.sendMessage(fileMentionPopup.expandMentions(messageInput.text))
messageInput.text = "" messageInput.text = ""
fileMentionPopup.clearMentions() fileMentionPopup.clearMentions()
@@ -689,13 +703,122 @@ ChatRootView {
onAccepted: root.compressCurrentChat() onAccepted: root.compressCurrentChat()
} }
Toast { Rectangle {
id: errorToast id: errorBanner
z: 1000
color: Qt.rgba(0.8, 0.2, 0.2, 0.9) z: 1000
border.color: Qt.darker(infoToast.color, 1.3) visible: root.hasActiveError && root.lastErrorMessage.length > 0
toastTextColor: "#FFFFFF"
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 { Toast {
@@ -735,7 +858,7 @@ ChatRootView {
target: root target: root
function onLastErrorMessageChanged() { function onLastErrorMessageChanged() {
if (root.lastErrorMessage.length > 0) { if (root.lastErrorMessage.length > 0) {
errorToast.show(root.lastErrorMessage) root.hasActiveError = true
} }
} }
function onLastInfoMessageChanged() { function onLastInfoMessageChanged() {

View File

@@ -19,6 +19,7 @@ Rectangle {
property alias cancelCompressButton: cancelCompressButtonId property alias cancelCompressButton: cancelCompressButtonId
property bool isCompressing: false property bool isCompressing: false
property bool isProcessing: false
property alias sendButtonTooltip: sendButtonTooltipId property alias sendButtonTooltip: sendButtonTooltipId
color: palette.window.hslLightness > 0.5 ? color: palette.window.hslLightness > 0.5 ?
@@ -159,11 +160,25 @@ Rectangle {
QoAButton { QoAButton {
id: sendButtonId id: sendButtonId
leftPadding: root.isProcessing ? 22 : 4
icon { icon {
height: 15 height: 15
width: 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 { QoAToolTip {
id: sendButtonTooltipId id: sendButtonTooltipId

View File

@@ -7,6 +7,8 @@ import QtQuick.Controls.Basic
Button { Button {
id: control id: control
property color accentColor: "transparent"
focusPolicy: Qt.NoFocus focusPolicy: Qt.NoFocus
padding: 4 padding: 4
@@ -18,11 +20,15 @@ Button {
background: Rectangle { background: Rectangle {
id: bg id: bg
readonly property bool hasAccent: control.accentColor.a > 0
implicitHeight: 20 implicitHeight: 20
color: !control.enabled || !control.down ? control.palette.button : control.palette.dark 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.color: bg.hasAccent
border.width: 1 ? control.accentColor
: (!control.enabled || (!control.hovered && !control.visualFocus) ? control.palette.mid : control.palette.highlight)
border.width: bg.hasAccent ? 2 : 1
radius: 4 radius: 4
Rectangle { Rectangle {
@@ -35,5 +41,13 @@ Button {
opacity: control.hovered ? 0.3 : 0.01 opacity: control.hovered ? 0.3 : 0.01
Behavior on opacity {NumberAnimation{duration: 250}} Behavior on opacity {NumberAnimation{duration: 250}}
} }
Rectangle {
anchors.fill: bg
radius: bg.radius
color: control.accentColor
visible: bg.hasAccent
opacity: 0.15
}
} }
} }

View File

@@ -309,7 +309,7 @@ public:
sendMessageAction.setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT)); sendMessageAction.setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
sendMessageAction.setText(Tr::tr("Send QodeAssist Chat Message")); sendMessageAction.setText(Tr::tr("Send QodeAssist Chat Message"));
sendMessageAction.setToolTip(Tr::tr("Send the current message to the LLM")); 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, [] { sendMessageAction.addOnTriggered(this, [] {
if (auto chatWidget = Chat::ChatWidget::focusedInstance()) if (auto chatWidget = Chat::ChatWidget::focusedInstance())
chatWidget->sendMessage(); chatWidget->sendMessage();