diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index 4db2daf..4c4b78a 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -44,9 +44,11 @@ qt_add_qml_module(QodeAssistChatView icons/window-unlock.svg icons/chat-icon.svg icons/chat-pause-icon.svg + icons/new-chat-icon.svg icons/rules-icon.svg icons/context-icon.svg icons/open-in-editor.svg + icons/open-in-window.svg icons/apply-changes-button.svg icons/undo-changes-button.svg icons/reject-changes-button.svg diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index 790e8fc..689a055 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -112,6 +112,18 @@ ChatRootView::ChatRootView(QQuickItem *parent) setRecentFilePath(QString{}); m_fileEditController->clearCurrentRequestId(); }); + auto maybeEmitTitle = [this] { + const QString newTitle = computeChatTitle(); + if (newTitle == m_cachedChatTitle) + return; + m_cachedChatTitle = newTitle; + emit chatTitleChanged(); + }; + connect(m_chatModel, &ChatModel::modelReseted, this, maybeEmitTitle); + connect(m_chatModel, &QAbstractItemModel::modelReset, this, maybeEmitTitle); + connect(m_chatModel, &QAbstractItemModel::rowsInserted, this, maybeEmitTitle); + connect(m_chatModel, &QAbstractItemModel::rowsRemoved, this, maybeEmitTitle); + connect(m_chatModel, &QAbstractItemModel::dataChanged, this, maybeEmitTitle); connect(this, &ChatRootView::attachmentFilesChanged, this, [this]() { m_tokenCounter->setAttachments(m_attachmentFiles); }); @@ -792,6 +804,51 @@ void ChatRootView::triggerOpenChatCommand(Utils::Id commandId) } } +bool ChatRootView::isInEditor() const +{ + return m_isInEditor; +} + +void ChatRootView::setInEditor(bool value) +{ + if (m_isInEditor == value) + return; + m_isInEditor = value; + emit isInEditorChanged(); +} + +void ChatRootView::requestNewChat() +{ + triggerOpenChatCommand(Constants::QODE_ASSIST_NEW_CHAT_ACTION); +} + +QString ChatRootView::chatTitle() const +{ + if (m_cachedChatTitle.isEmpty()) + m_cachedChatTitle = computeChatTitle(); + return m_cachedChatTitle; +} + +QString ChatRootView::computeChatTitle() const +{ + if (!m_chatModel) + return {}; + const auto history = m_chatModel->getChatHistory(); + for (const auto &msg : history) { + if (msg.role != ChatModel::User) + continue; + const QString content = msg.content.trimmed(); + if (content.isEmpty()) + continue; + const QString firstLine = content.section(QChar('\n'), 0, 0).trimmed(); + constexpr int maxLen = 60; + if (firstLine.length() > maxLen) + return firstLine.left(maxLen - 1) + QChar(0x2026); + return firstLine; + } + return {}; +} + void ChatRootView::handOffSession() { if (m_chatModel->rowCount() > 0) { @@ -824,7 +881,7 @@ void ChatRootView::consumePendingChatFile() void ChatRootView::relocateToSplit() { handOffSession(); - triggerOpenChatCommand(Constants::QODE_ASSIST_SHOW_CHAT_ACTION); + triggerOpenChatCommand(Constants::QODE_ASSIST_NEW_CHAT_ACTION); clearMessages(); clearAttachmentFiles(); emit closeHostRequested(); diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index 258b8b0..bdf6ff2 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -63,6 +63,8 @@ class ChatRootView : public QQuickItem Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL) Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL) Q_PROPERTY(bool isCompressing READ isCompressing NOTIFY isCompressingChanged FINAL) + Q_PROPERTY(bool isInEditor READ isInEditor NOTIFY isInEditorChanged FINAL) + Q_PROPERTY(QString chatTitle READ chatTitle NOTIFY chatTitleChanged FINAL) QML_ELEMENT @@ -183,6 +185,13 @@ public: bool isCompressing() const; + bool isInEditor() const; + void setInEditor(bool value); + + QString chatTitle() const; + + Q_INVOKABLE void requestNewChat(); + public slots: void sendMessage(const QString &message); void copyToClipboard(const QString &text); @@ -228,11 +237,15 @@ signals: void compressionCompleted(const QString &compressedChatPath); void compressionFailed(const QString &error); + void isInEditorChanged(); + void chatTitleChanged(); + void openFilesChanged(); void closeHostRequested(); private: + QString computeChatTitle() const; void triggerOpenChatCommand(Utils::Id commandId); void handOffSession(); bool deferSendForAutoCompress( @@ -271,6 +284,8 @@ private: }; PendingSend m_pendingSend; bool m_isSyncOpenFiles; + bool m_isInEditor = false; + mutable QString m_cachedChatTitle; QList m_currentEditors; bool m_isRequestInProgress; QString m_lastErrorMessage; diff --git a/ChatView/ChatWidget.cpp b/ChatView/ChatWidget.cpp index bc41766..dbcadc8 100644 --- a/ChatView/ChatWidget.cpp +++ b/ChatView/ChatWidget.cpp @@ -21,6 +21,7 @@ ChatWidget::ChatWidget( QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, Skills::SkillsManager *skillsManager, + bool registerOwnContext, QWidget *parent) : QQuickWidget{engine, parent} { @@ -37,10 +38,19 @@ ChatWidget::ChatWidget( setResizeMode(QQuickWidget::SizeRootObjectToView); setFocusPolicy(Qt::StrongFocus); - auto ideContext = new Core::IContext{this}; - ideContext->setWidget(this); - ideContext->setContext(Core::Context{Constants::QODE_ASSIST_CHAT_CONTEXT}); - Core::ICore::addContextObject(ideContext); + if (registerOwnContext) { + auto ideContext = new Core::IContext{this}; + ideContext->setWidget(this); + ideContext->setContext(Core::Context{Constants::QODE_ASSIST_CHAT_CONTEXT}); + Core::ICore::addContextObject(ideContext); + } +} + +void ChatWidget::focusInEvent(QFocusEvent *event) +{ + QQuickWidget::focusInEvent(event); + if (rootObject()) + QMetaObject::invokeMethod(rootObject(), "focusInput"); } void ChatWidget::clear() diff --git a/ChatView/ChatWidget.hpp b/ChatView/ChatWidget.hpp index 8f3aa4d..0ce931d 100644 --- a/ChatView/ChatWidget.hpp +++ b/ChatView/ChatWidget.hpp @@ -22,6 +22,7 @@ public: QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, Skills::SkillsManager *skillsManager, + bool registerOwnContext = true, QWidget *parent = nullptr); ~ChatWidget() = default; @@ -38,6 +39,9 @@ public: signals: void clearPressed(); + +protected: + void focusInEvent(QFocusEvent *event) override; }; } // namespace QodeAssist::Chat diff --git a/ChatView/icons/new-chat-icon.svg b/ChatView/icons/new-chat-icon.svg new file mode 100644 index 0000000..32ca627 --- /dev/null +++ b/ChatView/icons/new-chat-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ChatView/icons/open-in-editor.svg b/ChatView/icons/open-in-editor.svg index d7dd273..f442525 100644 --- a/ChatView/icons/open-in-editor.svg +++ b/ChatView/icons/open-in-editor.svg @@ -1,17 +1,6 @@ - - - - - - - - - - - - - - - - + + + + + diff --git a/ChatView/icons/open-in-window.svg b/ChatView/icons/open-in-window.svg new file mode 100644 index 0000000..d968d4c --- /dev/null +++ b/ChatView/icons/open-in-window.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index 941273a..5d94861 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -87,21 +87,25 @@ ChatRootView { Layout.preferredWidth: parent.width Layout.preferredHeight: childrenRect.height + 10 + isInEditor: root.isInEditor saveButton.onClicked: root.showSaveDialog() loadButton.onClicked: root.showLoadDialog() clearButton.onClicked: root.clearChat() + newChatButton.onClicked: root.requestNewChat() tokensBadge { - readonly property int sessionCached: root.chatModel.sessionCachedPromptTokens + readonly property int sessionPrompt: root.chatModel.sessionPromptTokens || 0 + readonly property int sessionCompletion: root.chatModel.sessionCompletionTokens || 0 + readonly property int sessionCached: root.chatModel.sessionCachedPromptTokens || 0 text: sessionCached > 0 ? qsTr("next ~%1 · session ↑%2 ↓%3 ↻%4") .arg(root.inputTokensCount) - .arg(root.chatModel.sessionPromptTokens) - .arg(root.chatModel.sessionCompletionTokens) + .arg(sessionPrompt) + .arg(sessionCompletion) .arg(sessionCached) : qsTr("next ~%1 · session ↑%2 ↓%3") .arg(root.inputTokensCount) - .arg(root.chatModel.sessionPromptTokens) - .arg(root.chatModel.sessionCompletionTokens) + .arg(sessionPrompt) + .arg(sessionCompletion) ToolTip.text: sessionCached > 0 ? qsTr("next request (estimate) · session prompt ↑ / completion ↓ / cached ↻ (provider cache hits)") : qsTr("next request (estimate) · session prompt ↑ / completion ↓") @@ -117,8 +121,11 @@ ChatRootView { onCheckedChanged: _chatview.isPin = topBar.pinButton.checked } relocateButton { + icon.source: (typeof _chatview !== 'undefined') + ? "qrc:/qt/qml/ChatView/icons/open-in-editor.svg" + : "qrc:/qt/qml/ChatView/icons/open-in-window.svg" ToolTip.text: (typeof _chatview !== 'undefined') - ? qsTr("Move this chat to an editor split") + ? qsTr("Move this chat to an editor tab") : qsTr("Move this chat to a separate window") onClicked: { if (typeof _chatview !== 'undefined') diff --git a/ChatView/qml/controls/TopBar.qml b/ChatView/qml/controls/TopBar.qml index bd8b763..780f2f7 100644 --- a/ChatView/qml/controls/TopBar.qml +++ b/ChatView/qml/controls/TopBar.qml @@ -10,9 +10,12 @@ import UIControls Rectangle { id: root + property bool isInEditor: false + property alias saveButton: saveButtonId property alias loadButton: loadButtonId property alias clearButton: clearButtonId + property alias newChatButton: newChatButtonId property alias tokensBadge: tokensBadgeId property alias recentPath: recentPathId property alias openChatHistory: openChatHistoryId @@ -77,6 +80,43 @@ Rectangle { ToolTip.delay: 250 } + QoASeparator { + anchors.verticalCenter: parent.verticalCenter + } + + QoAButton { + id: clearButtonId + + icon { + source: "qrc:/qt/qml/ChatView/icons/clean-icon-dark.svg" + height: 15 + width: 8 + } + ToolTip.visible: hovered + ToolTip.delay: 250 + ToolTip.text: qsTr("Clean chat") + } + + QoASeparator { + anchors.verticalCenter: parent.verticalCenter + } + + QoAButton { + id: newChatButtonId + + visible: root.isInEditor + + icon { + source: "qrc:/qt/qml/ChatView/icons/new-chat-icon.svg" + color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF" + height: 15 + width: 15 + } + ToolTip.visible: hovered + ToolTip.delay: 250 + ToolTip.text: qsTr("Open new chat in a new tab") + } + QoAComboBox { id: configSelectorId @@ -276,21 +316,6 @@ Rectangle { ToolTip.delay: 250 ToolTip.text: qsTr("Current amount tokens in chat and LLM limit threshold") } - - QoASeparator {} - - QoAButton { - id: clearButtonId - - icon { - source: "qrc:/qt/qml/ChatView/icons/clean-icon-dark.svg" - height: 15 - width: 8 - } - ToolTip.visible: hovered - ToolTip.delay: 250 - ToolTip.text: qsTr("Clean chat") - } } } } diff --git a/QodeAssistConstants.hpp b/QodeAssistConstants.hpp index 21d3674..d1acbcb 100644 --- a/QodeAssistConstants.hpp +++ b/QodeAssistConstants.hpp @@ -16,6 +16,7 @@ const char QODE_ASSIST_CHAT_EDITOR_ID[] = "QodeAssist.ChatEditor"; const char QODE_ASSIST_SHOW_CHAT_ACTION[] = "QodeAssist.ShowChatView"; const char QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION[] = "QodeAssist.OpenChatWindow"; +const char QODE_ASSIST_NEW_CHAT_ACTION[] = "QodeAssist.NewChat"; const char QODE_ASSIST_CHAT_SEND_MESSAGE[] = "QodeAssist.Chat.SendMessage"; const char QODE_ASSIST_CHAT_CLEAR_SESSION[] = "QodeAssist.Chat.ClearSession"; diff --git a/chat/ChatEditor.cpp b/chat/ChatEditor.cpp index e9fde07..aaf04c4 100644 --- a/chat/ChatEditor.cpp +++ b/chat/ChatEditor.cpp @@ -3,17 +3,13 @@ #include "ChatEditor.hpp" -#include -#include -#include #include -#include - #include "ChatDocument.hpp" #include "ChatView/ChatRootView.hpp" #include "ChatView/ChatWidget.hpp" #include "QodeAssistConstants.hpp" +#include "QodeAssisttr.h" namespace QodeAssist::Chat { @@ -25,26 +21,28 @@ ChatEditor::ChatEditor( , m_sessionFileRegistry(sessionFileRegistry) , m_skillsManager(skillsManager) , m_document(new ChatDocument(this)) - , m_chatWidget(new ChatWidget(engine, sessionFileRegistry, skillsManager)) + , m_chatWidget(new ChatWidget(engine, sessionFileRegistry, skillsManager, false)) { setWidget(m_chatWidget); setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT)); - setDuplicateSupported(true); + setDuplicateSupported(false); if (auto rootView = qobject_cast(m_chatWidget->rootObject())) { + rootView->setInEditor(true); connect( rootView, &ChatRootView::closeHostRequested, this, - [this] { - Core::EditorManager::closeEditors({this}); - if (auto command - = Core::ActionManager::command(Core::Constants::REMOVE_CURRENT_SPLIT)) { - if (auto action = command->action(); action && action->isEnabled()) - action->trigger(); - } - }, + [this] { Core::EditorManager::closeEditors({this}); }, Qt::QueuedConnection); + + auto syncTitle = [this, rootView] { + const QString title = rootView->chatTitle(); + m_document->setPreferredDisplayName( + title.isEmpty() ? Tr::tr("QodeAssist Chat") : QStringLiteral("QodeAssist - ") + title); + }; + connect(rootView, &ChatRootView::chatTitleChanged, this, syncTitle); + syncTitle(); } } @@ -71,7 +69,7 @@ QWidget *ChatEditor::toolBar() Core::IEditor *ChatEditor::duplicate() { - return new ChatEditor(m_engine, m_sessionFileRegistry, m_skillsManager); + return nullptr; } } // namespace QodeAssist::Chat diff --git a/chat/ChatEditor.hpp b/chat/ChatEditor.hpp index ab163df..d403c48 100644 --- a/chat/ChatEditor.hpp +++ b/chat/ChatEditor.hpp @@ -17,8 +17,6 @@ class ChatDocument; class ChatWidget; class SessionFileRegistry; -// Editor-area host for the chat. Each editor (including a split duplicate) owns its own -// ChatWidget and therefore its own independent chat session. class ChatEditor : public Core::IEditor { Q_OBJECT diff --git a/qodeassist.cpp b/qodeassist.cpp index 76bd03c..ebec0f8 100644 --- a/qodeassist.cpp +++ b/qodeassist.cpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include #include #include #include @@ -228,10 +230,10 @@ public: ActionBuilder showChatViewAction(this, Constants::QODE_ASSIST_SHOW_CHAT_ACTION); const QKeySequence showChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_W); showChatViewAction.setDefaultKeySequence(showChatViewShortcut); - showChatViewAction.setToolTip(Tr::tr("Open QodeAssist Chat in an editor split")); + showChatViewAction.setToolTip(Tr::tr("Open QodeAssist Chat as an editor tab")); showChatViewAction.setText(Tr::tr("Show QodeAssist Chat")); showChatViewAction.setIcon(QCODEASSIST_CHAT_ICON.icon()); - showChatViewAction.addOnTriggered(this, [this] { openChatInSplit(); }); + showChatViewAction.addOnTriggered(this, [this] { openChatInEditor(); }); m_statusWidget->setChatButtonAction(showChatViewAction.contextAction()); m_chatButtonMenu = new QMenu(m_statusWidget); @@ -260,6 +262,12 @@ public: openChatWindowAction.setIcon(QCODEASSIST_CHAT_ICON.icon()); openChatWindowAction.addOnTriggered(this, [this] { openChatInWindow(); }); + ActionBuilder newChatAction(this, Constants::QODE_ASSIST_NEW_CHAT_ACTION); + newChatAction.setText(Tr::tr("New QodeAssist Chat")); + newChatAction.setToolTip(Tr::tr("Open a fresh chat in a new editor tab")); + newChatAction.setIcon(QCODEASSIST_CHAT_ICON.icon()); + newChatAction.addOnTriggered(this, [this] { openNewChatInEditor(); }); + ActionBuilder sendMessageAction(this, Constants::QODE_ASSIST_CHAT_SEND_MESSAGE); sendMessageAction.setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT)); sendMessageAction.setText(Tr::tr("Send QodeAssist Chat Message")); @@ -323,13 +331,14 @@ public: } private: - void openChatInSplit() + void openChatInEditor() { - if (auto splitCommand - = Core::ActionManager::command(Core::Constants::SPLIT_SIDE_BY_SIDE)) { - if (auto splitAction = splitCommand->action()) - splitAction->trigger(); + if (auto existing = findExistingChatEditor()) { + Core::EditorManager::activateEditor(existing); + existing->consumePendingChatFile(); + return; } + QString title = Tr::tr("QodeAssist Chat"); Core::IEditor *editor = Core::EditorManager::openEditorWithContents( Constants::QODE_ASSIST_CHAT_EDITOR_ID, &title, {}, QUuid::createUuid().toString()); @@ -337,6 +346,34 @@ private: chatEditor->consumePendingChatFile(); } + void openNewChatInEditor() + { + QString title = Tr::tr("QodeAssist Chat"); + Core::IEditor *editor = Core::EditorManager::openEditorWithContents( + Constants::QODE_ASSIST_CHAT_EDITOR_ID, &title, {}, QUuid::createUuid().toString()); + // For the "New Chat" button pending is empty (no-op). For relocate-to-editor it + // carries the handed-off chat file and gets loaded into the freshly opened tab. + if (auto chatEditor = qobject_cast(editor)) + chatEditor->consumePendingChatFile(); + } + + Chat::ChatEditor *findExistingChatEditor() const + { + const auto entries = Core::DocumentModel::entries(); + for (auto *entry : entries) { + if (!entry || !entry->document) + continue; + if (entry->document->id() != Constants::QODE_ASSIST_CHAT_EDITOR_ID) + continue; + const auto editors = Core::DocumentModel::editorsForDocument(entry->document); + for (auto *editor : editors) { + if (auto chatEditor = qobject_cast(editor)) + return chatEditor; + } + } + return nullptr; + } + void openChatInWindow() { if (!m_chatView) @@ -400,11 +437,11 @@ private: m_chatButtonMenu->addSeparator(); if (m_chatView && m_chatView->isVisible()) { - QAction *splitAction = m_chatButtonMenu->addAction(Tr::tr("Open Chat in Split")); - connect(splitAction, &QAction::triggered, this, [this] { + QAction *editorAction = m_chatButtonMenu->addAction(Tr::tr("Open Chat in Editor")); + connect(editorAction, &QAction::triggered, this, [this] { if (m_chatView) m_chatView->close(); - openChatInSplit(); + openChatInEditor(); }); } else { QAction *windowAction