diff --git a/CMakeLists.txt b/CMakeLists.txt index cacd66b..55f20ef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -115,6 +115,9 @@ add_qtc_plugin(QodeAssist QodeAssistClient.hpp QodeAssistClient.cpp chat/ChatOutputPane.h chat/ChatOutputPane.cpp chat/NavigationPanel.hpp chat/NavigationPanel.cpp + chat/ChatDocument.hpp chat/ChatDocument.cpp + chat/ChatEditor.hpp chat/ChatEditor.cpp + chat/ChatEditorFactory.hpp chat/ChatEditorFactory.cpp ConfigurationManager.hpp ConfigurationManager.cpp CodeHandler.hpp CodeHandler.cpp UpdateStatusWidget.hpp UpdateStatusWidget.cpp diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index f8a3086..021de4d 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -75,6 +75,7 @@ qt_add_qml_module(QodeAssistChatView InputTokenCounter.hpp InputTokenCounter.cpp ChatHistoryStore.hpp ChatHistoryStore.cpp FileMentionItem.hpp FileMentionItem.cpp + SessionFileRegistry.hpp SessionFileRegistry.cpp ) target_link_libraries(QodeAssistChatView diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index 9464f17..4854081 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -3,6 +3,7 @@ #include "ChatRootView.hpp" +#include #include #include #include @@ -10,8 +11,12 @@ #include #include #include +#include +#include #include +#include +#include #include #include #include @@ -19,6 +24,8 @@ #include #include +#include "QodeAssistConstants.hpp" + #include "AgentRoleController.hpp" #include "ChatAssistantSettings.hpp" #include "ChatConfigurationController.hpp" @@ -30,11 +37,20 @@ #include "SettingsConstants.hpp" #include "Logger.hpp" #include "ProvidersManager.hpp" +#include "SessionFileRegistry.hpp" #include "context/ContextManager.hpp" #include "pluginllmcore/RulesLoader.hpp" namespace QodeAssist::Chat { +namespace { +bool isChatEditor(Core::IEditor *editor) +{ + return editor && editor->document() + && editor->document()->id() == Utils::Id(Constants::QODE_ASSIST_CHAT_EDITOR_ID); +} +} // namespace + ChatRootView::ChatRootView(QQuickItem *parent) : QQuickItem(parent) , m_chatModel(new ChatModel(this)) @@ -278,6 +294,25 @@ ChatRootView::ChatRootView(QQuickItem *parent) }); } +ChatRootView::~ChatRootView() +{ + if (m_sessionFileRegistry && !m_recentFilePath.isEmpty()) { + m_sessionFileRegistry->release(m_recentFilePath); + } +} + +SessionFileRegistry *ChatRootView::sessionFileRegistry() const +{ + if (!m_sessionFileRegistryResolved) { + m_sessionFileRegistryResolved = true; + if (auto context = qmlContext(this)) { + m_sessionFileRegistry = qobject_cast( + context->contextProperty("sessionFileRegistry").value()); + } + } + return m_sessionFileRegistry; +} + ChatModel *ChatRootView::chatModel() const { return m_chatModel; @@ -341,6 +376,9 @@ void ChatRootView::dispatchSend( { if (m_recentFilePath.isEmpty()) { QString filePath = getAutosaveFilePath(message, attachments); + if (auto registry = sessionFileRegistry()) { + filePath = registry->uniqueFreePath(filePath); + } if (!filePath.isEmpty()) { setRecentFilePath(filePath); LOG_MESSAGE(QString("Set chat file path for new chat: %1").arg(filePath)); @@ -402,6 +440,15 @@ QString ChatRootView::currentTemplate() const void ChatRootView::saveHistory(const QString &filePath) { + if (filePath != m_recentFilePath) { + if (auto registry = sessionFileRegistry(); registry && registry->isLocked(filePath)) { + m_lastErrorMessage + = tr("This chat file is already in use by another QodeAssist chat session."); + emit lastErrorMessageChanged(); + return; + } + } + auto result = m_historyStore->save(filePath); if (!result.success) { LOG_MESSAGE(QString("Failed to save chat history: %1").arg(result.errorMessage)); @@ -412,6 +459,15 @@ void ChatRootView::saveHistory(const QString &filePath) void ChatRootView::loadHistory(const QString &filePath) { + if (filePath != m_recentFilePath) { + if (auto registry = sessionFileRegistry(); registry && registry->isLocked(filePath)) { + m_lastErrorMessage + = tr("This chat is already open in another QodeAssist chat session."); + emit lastErrorMessageChanged(); + return; + } + } + auto result = m_historyStore->load(filePath); if (!result.success) { LOG_MESSAGE(QString("Failed to load chat history: %1").arg(result.errorMessage)); @@ -446,11 +502,18 @@ void ChatRootView::autosave() return; } - QString filePath = getAutosaveFilePath(); - if (!filePath.isEmpty()) { - m_historyStore->save(filePath); + if (m_recentFilePath.isEmpty()) { + QString filePath = getAutosaveFilePath(); + if (auto registry = sessionFileRegistry()) { + filePath = registry->uniqueFreePath(filePath); + } + if (filePath.isEmpty()) { + return; + } setRecentFilePath(filePath); } + + m_historyStore->save(m_recentFilePath); } QString ChatRootView::getAutosaveFilePath() const @@ -671,6 +734,76 @@ void ChatRootView::openFileInEditor(const QString &filePath) Core::EditorManager::openEditor(Utils::FilePath::fromString(filePath)); } +void ChatRootView::triggerOpenChatCommand(Utils::Id commandId) +{ + if (auto command = Core::ActionManager::command(commandId)) { + if (auto action = command->action()) + action->trigger(); + } +} + +void ChatRootView::handOffSession() +{ + if (m_chatModel->rowCount() > 0) { + if (m_recentFilePath.isEmpty()) { + QString filePath = getAutosaveFilePath(); + if (auto registry = sessionFileRegistry()) + filePath = registry->uniqueFreePath(filePath); + if (!filePath.isEmpty()) + setRecentFilePath(filePath); + } + if (!m_recentFilePath.isEmpty()) + m_historyStore->save(m_recentFilePath); + } + + if (auto registry = sessionFileRegistry(); registry && !m_recentFilePath.isEmpty()) + registry->setPendingChatFile(m_recentFilePath); + + setRecentFilePath(QString{}); +} + +void ChatRootView::consumePendingChatFile() +{ + if (auto registry = sessionFileRegistry()) { + const QString pending = registry->takePendingChatFile(); + if (!pending.isEmpty()) + loadHistory(pending); + } +} + +void ChatRootView::relocateToSplit() +{ + handOffSession(); + triggerOpenChatCommand(Constants::QODE_ASSIST_SHOW_CHAT_ACTION); + clearMessages(); + clearAttachmentFiles(); + emit closeHostRequested(); +} + +void ChatRootView::relocateToWindow() +{ + handOffSession(); + triggerOpenChatCommand(Constants::QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION); + clearMessages(); + clearAttachmentFiles(); + emit closeHostRequested(); + + // Closing the source split raises the main window; re-raise the chat window once that + // queued teardown has run. The registry outlives this view, which the split close deletes. + if (auto registry = sessionFileRegistry()) { + QMetaObject::invokeMethod( + registry, + [] { + if (auto command = Core::ActionManager::command( + Constants::QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION)) { + if (auto action = command->action()) + action->trigger(); + } + }, + Qt::QueuedConnection); + } +} + void ChatRootView::updateInputTokensCount() { m_tokenCounter->recompute(); @@ -688,6 +821,10 @@ bool ChatRootView::isSyncOpenFiles() const void ChatRootView::onEditorAboutToClose(Core::IEditor *editor) { + if (isChatEditor(editor)) { + return; + } + if (auto document = editor->document(); document && isSyncOpenFiles()) { QString filePath = document->filePath().toFSPathString(); m_linkedFiles.removeOne(filePath); @@ -703,6 +840,10 @@ void ChatRootView::onEditorAboutToClose(Core::IEditor *editor) void ChatRootView::onAppendLinkFileFromEditor(Core::IEditor *editor) { + if (isChatEditor(editor)) { + return; + } + if (auto document = editor->document(); document && isSyncOpenFiles()) { QString filePath = document->filePath().toFSPathString(); if (!m_linkedFiles.contains(filePath) && !shouldIgnoreFileForAttach(document->filePath())) { @@ -714,6 +855,10 @@ void ChatRootView::onAppendLinkFileFromEditor(Core::IEditor *editor) void ChatRootView::onEditorCreated(Core::IEditor *editor, const Utils::FilePath &filePath) { + if (isChatEditor(editor)) { + return; + } + if (editor && editor->document()) { m_currentEditors.append(editor); emit openFilesChanged(); @@ -732,12 +877,23 @@ QString ChatRootView::chatFilePath() const void ChatRootView::setRecentFilePath(const QString &filePath) { - if (m_recentFilePath != filePath) { - m_recentFilePath = filePath; - m_clientInterface->setChatFilePath(filePath); - m_fileManager->setChatFilePath(filePath); - emit chatFileNameChanged(); + if (m_recentFilePath == filePath) { + return; } + + if (auto registry = sessionFileRegistry()) { + if (!m_recentFilePath.isEmpty()) { + registry->release(m_recentFilePath); + } + if (!filePath.isEmpty()) { + registry->lock(filePath); + } + } + + m_recentFilePath = filePath; + m_clientInterface->setChatFilePath(filePath); + m_fileManager->setChatFilePath(filePath); + emit chatFileNameChanged(); } bool ChatRootView::shouldIgnoreFileForAttach(const Utils::FilePath &filePath) diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index 4da4ca6..72af42c 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -3,6 +3,7 @@ #pragma once +#include #include #include @@ -20,6 +21,7 @@ class ChatConfigurationController; class FileEditController; class InputTokenCounter; class ChatHistoryStore; +class SessionFileRegistry; class ChatRootView : public QQuickItem { @@ -62,6 +64,7 @@ class ChatRootView : public QQuickItem public: ChatRootView(QQuickItem *parent = nullptr); + ~ChatRootView() override; ChatModel *chatModel() const; QString currentTemplate() const; @@ -96,6 +99,11 @@ public: Q_INVOKABLE void openFileInEditor(const QString &filePath); + Q_INVOKABLE void relocateToSplit(); + Q_INVOKABLE void relocateToWindow(); + + void consumePendingChatFile(); + Q_INVOKABLE void updateInputTokensCount(); int inputTokensCount() const; @@ -216,7 +224,11 @@ signals: void openFilesChanged(); + void closeHostRequested(); + private: + void triggerOpenChatCommand(Utils::Id commandId); + void handOffSession(); bool deferSendForAutoCompress( const QString &message, const QStringList &attachments, @@ -231,6 +243,8 @@ private: bool useThinking); bool hasImageAttachments(const QStringList &attachments) const; + SessionFileRegistry *sessionFileRegistry() const; + ChatModel *m_chatModel; PluginLLMCore::PromptProviderChat m_promptProvider; ClientInterface *m_clientInterface; @@ -263,6 +277,8 @@ private: FileEditController *m_fileEditController; InputTokenCounter *m_tokenCounter; ChatHistoryStore *m_historyStore; + mutable QPointer m_sessionFileRegistry; + mutable bool m_sessionFileRegistryResolved = false; }; } // namespace QodeAssist::Chat diff --git a/ChatView/ChatView.cpp b/ChatView/ChatView.cpp index c94fc4a..b7da7a3 100644 --- a/ChatView/ChatView.cpp +++ b/ChatView/ChatView.cpp @@ -14,7 +14,9 @@ #include #include +#include "ChatRootView.hpp" #include "QodeAssistConstants.hpp" +#include "SessionFileRegistry.hpp" namespace { constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::WindowSystemMenuHint @@ -24,7 +26,7 @@ constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::Win namespace QodeAssist::Chat { -ChatView::ChatView(QQmlEngine* engine) +ChatView::ChatView(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry) : QQuickView{engine, nullptr} , m_isPin(false) { @@ -33,12 +35,23 @@ ChatView::ChatView(QQmlEngine* engine) { auto context = new QQmlContext{engine, this}; context->setContextProperty("_chatview", this); + context->setContextProperty("sessionFileRegistry", sessionFileRegistry); auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this}; auto rootItem = component->create(context); setContent(component->url(), component, rootItem); } + + if (auto rootView = qobject_cast(rootObject())) { + connect( + rootView, + &ChatRootView::closeHostRequested, + this, + &QWindow::close, + Qt::QueuedConnection); + } + setResizeMode(QQuickView::SizeRootObjectToView); setMinimumSize({400, 300}); setFlags(baseFlags); diff --git a/ChatView/ChatView.hpp b/ChatView/ChatView.hpp index baef928..0efb958 100644 --- a/ChatView/ChatView.hpp +++ b/ChatView/ChatView.hpp @@ -12,12 +12,14 @@ namespace QodeAssist::Chat { +class SessionFileRegistry; + class ChatView : public QQuickView { Q_OBJECT Q_PROPERTY(bool isPin READ isPin WRITE setIsPin NOTIFY isPinChanged FINAL) public: - ChatView(QQmlEngine* engine); + ChatView(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry); bool isPin() const; void setIsPin(bool newIsPin); diff --git a/ChatView/ChatWidget.cpp b/ChatView/ChatWidget.cpp index c8bf1e4..ea9c37d 100644 --- a/ChatView/ChatWidget.cpp +++ b/ChatView/ChatWidget.cpp @@ -12,15 +12,17 @@ #include #include "QodeAssistConstants.hpp" +#include "SessionFileRegistry.hpp" namespace QodeAssist::Chat { -ChatWidget::ChatWidget(QQmlEngine* engine, QWidget *parent) +ChatWidget::ChatWidget(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, QWidget *parent) : QQuickWidget{engine, parent} { /// @note setup quick view content { auto context = new QQmlContext{engine, this}; + context->setContextProperty("sessionFileRegistry", sessionFileRegistry); auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this}; auto rootItem = component->create(context); diff --git a/ChatView/ChatWidget.hpp b/ChatView/ChatWidget.hpp index 7bd85d0..841e95a 100644 --- a/ChatView/ChatWidget.hpp +++ b/ChatView/ChatWidget.hpp @@ -7,12 +7,17 @@ namespace QodeAssist::Chat { +class SessionFileRegistry; + class ChatWidget : public QQuickWidget { Q_OBJECT public: - explicit ChatWidget(QQmlEngine* engine, QWidget *parent = nullptr); + explicit ChatWidget( + QQmlEngine *engine, + SessionFileRegistry *sessionFileRegistry, + QWidget *parent = nullptr); ~ChatWidget() = default; Q_INVOKABLE void clear(); diff --git a/ChatView/SessionFileRegistry.cpp b/ChatView/SessionFileRegistry.cpp new file mode 100644 index 0000000..93061ee --- /dev/null +++ b/ChatView/SessionFileRegistry.cpp @@ -0,0 +1,67 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "SessionFileRegistry.hpp" + +#include + +#include + +namespace QodeAssist::Chat { + +SessionFileRegistry::SessionFileRegistry(QObject *parent) + : QObject(parent) +{} + +bool SessionFileRegistry::isLocked(const QString &path) const +{ + return !path.isEmpty() && m_lockedPaths.contains(path); +} + +bool SessionFileRegistry::lock(const QString &path) +{ + if (path.isEmpty() || m_lockedPaths.contains(path)) { + return false; + } + m_lockedPaths.insert(path); + return true; +} + +void SessionFileRegistry::release(const QString &path) +{ + m_lockedPaths.remove(path); +} + +void SessionFileRegistry::setPendingChatFile(const QString &path) +{ + m_pendingChatFile = path; +} + +QString SessionFileRegistry::takePendingChatFile() +{ + return std::exchange(m_pendingChatFile, QString{}); +} + +QString SessionFileRegistry::uniqueFreePath(const QString &desiredPath) const +{ + if (desiredPath.isEmpty() || !m_lockedPaths.contains(desiredPath)) { + return desiredPath; + } + + const QFileInfo info(desiredPath); + const QString dir = info.path(); + const QString base = info.completeBaseName(); + const QString suffix = info.suffix(); + + for (int counter = 2;; ++counter) { + QString candidate = dir + '/' + base + '_' + QString::number(counter); + if (!suffix.isEmpty()) { + candidate += '.' + suffix; + } + if (!m_lockedPaths.contains(candidate)) { + return candidate; + } + } +} + +} // namespace QodeAssist::Chat diff --git a/ChatView/SessionFileRegistry.hpp b/ChatView/SessionFileRegistry.hpp new file mode 100644 index 0000000..d3c32a8 --- /dev/null +++ b/ChatView/SessionFileRegistry.hpp @@ -0,0 +1,38 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +namespace QodeAssist::Chat { + +// Shared registry of chat session (autosave) file paths that are currently held by a live +// chat instance. Lets every chat view — bottom pane, navigation panel, editor split — claim +// a unique history file so two sessions never autosave into the same path. +class SessionFileRegistry : public QObject +{ + Q_OBJECT + +public: + explicit SessionFileRegistry(QObject *parent = nullptr); + + bool isLocked(const QString &path) const; + bool lock(const QString &path); + void release(const QString &path); + + QString uniqueFreePath(const QString &desiredPath) const; + + // Handoff slot for relocating a live chat between hosts (split <-> window): the source + // chat stores its history file here, the freshly created host picks it up exactly once. + void setPendingChatFile(const QString &path); + QString takePendingChatFile(); + +private: + QSet m_lockedPaths; + QString m_pendingChatFile; +}; + +} // namespace QodeAssist::Chat diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index a478e28..95ced9f 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -116,6 +116,17 @@ ChatRootView { checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false onCheckedChanged: _chatview.isPin = topBar.pinButton.checked } + relocateButton { + ToolTip.text: (typeof _chatview !== 'undefined') + ? qsTr("Move this chat to an editor split") + : qsTr("Move this chat to a separate window") + onClicked: { + if (typeof _chatview !== 'undefined') + root.relocateToSplit() + else + root.relocateToWindow() + } + } toolsButton { checked: root.useTools onCheckedChanged: { diff --git a/ChatView/qml/controls/TopBar.qml b/ChatView/qml/controls/TopBar.qml index a783150..bd8b763 100644 --- a/ChatView/qml/controls/TopBar.qml +++ b/ChatView/qml/controls/TopBar.qml @@ -17,6 +17,7 @@ Rectangle { property alias recentPath: recentPathId property alias openChatHistory: openChatHistoryId property alias pinButton: pinButtonId + property alias relocateButton: relocateButtonId property alias contextButton: contextButtonId property alias toolsButton: toolsButtonId property alias thinkingMode: thinkingModeId @@ -61,6 +62,21 @@ Rectangle { : qsTr("Pin chat window to the top") } + QoAButton { + id: relocateButtonId + + anchors.verticalCenter: parent.verticalCenter + + icon { + source: "qrc:/qt/qml/ChatView/icons/open-in-editor.svg" + color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF" + height: 15 + width: 15 + } + ToolTip.visible: hovered + ToolTip.delay: 250 + } + QoAComboBox { id: configSelectorId diff --git a/QodeAssistConstants.hpp b/QodeAssistConstants.hpp index b4446d8..21d3674 100644 --- a/QodeAssistConstants.hpp +++ b/QodeAssistConstants.hpp @@ -12,6 +12,10 @@ const char QODE_ASSIST_REQUEST_SUGGESTION[] = "QodeAssist.RequestSuggestion"; const char QODE_ASSIST_CHAT_CONTEXT[] = "QodeAssist.ChatContext"; const char QODE_ASSIST_CHAT_NAV_ID[] = "QodeAssistChat"; +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_CHAT_SEND_MESSAGE[] = "QodeAssist.Chat.SendMessage"; const char QODE_ASSIST_CHAT_CLEAR_SESSION[] = "QodeAssist.Chat.ClearSession"; diff --git a/UpdateStatusWidget.cpp b/UpdateStatusWidget.cpp index 12c0ff1..95fb740 100644 --- a/UpdateStatusWidget.cpp +++ b/UpdateStatusWidget.cpp @@ -3,6 +3,8 @@ #include "UpdateStatusWidget.hpp" +#include + namespace QodeAssist { UpdateStatusWidget::UpdateStatusWidget(QWidget *parent) @@ -57,6 +59,16 @@ void UpdateStatusWidget::setChatButtonAction(QAction *action) m_chatButton->setDefaultAction(action); } +void UpdateStatusWidget::setChatButtonMenu(QMenu *menu) +{ + m_chatButton->setMenu(menu); + m_chatButton->setPopupMode(QToolButton::DelayedPopup); + m_chatButton->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_chatButton, &QWidget::customContextMenuRequested, m_chatButton, [this, menu](const QPoint &pos) { + menu->exec(m_chatButton->mapToGlobal(pos)); + }); +} + QPushButton *UpdateStatusWidget::updateButton() const { return m_updateButton; diff --git a/UpdateStatusWidget.hpp b/UpdateStatusWidget.hpp index 30165d2..b54b4dc 100644 --- a/UpdateStatusWidget.hpp +++ b/UpdateStatusWidget.hpp @@ -9,6 +9,10 @@ #include #include +QT_BEGIN_NAMESPACE +class QMenu; +QT_END_NAMESPACE + namespace QodeAssist { class UpdateStatusWidget : public QFrame @@ -21,6 +25,7 @@ public: void showUpdateAvailable(const QString &version); void hideUpdateInfo(); void setChatButtonAction(QAction *action); + void setChatButtonMenu(QMenu *menu); QPushButton *updateButton() const; diff --git a/chat/ChatDocument.cpp b/chat/ChatDocument.cpp new file mode 100644 index 0000000..641cff6 --- /dev/null +++ b/chat/ChatDocument.cpp @@ -0,0 +1,47 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ChatDocument.hpp" + +#include + +#include "QodeAssistConstants.hpp" +#include "QodeAssisttr.h" + +namespace QodeAssist::Chat { + +ChatDocument::ChatDocument(QObject *parent) + : Core::IDocument(parent) +{ + setId(Constants::QODE_ASSIST_CHAT_EDITOR_ID); + setMimeType("text/plain"); + setTemporary(true); + setPreferredDisplayName(Tr::tr("QodeAssist Chat")); +} + +QByteArray ChatDocument::contents() const +{ + return {}; +} + +Utils::Result<> ChatDocument::setContents(const QByteArray &) +{ + return Utils::ResultOk; +} + +bool ChatDocument::isModified() const +{ + return false; +} + +bool ChatDocument::isSaveAsAllowed() const +{ + return false; +} + +bool ChatDocument::shouldAutoSave() const +{ + return false; +} + +} // namespace QodeAssist::Chat diff --git a/chat/ChatDocument.hpp b/chat/ChatDocument.hpp new file mode 100644 index 0000000..d7dd137 --- /dev/null +++ b/chat/ChatDocument.hpp @@ -0,0 +1,27 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +namespace QodeAssist::Chat { + +// Backing document for a chat editor view. The chat persists itself through its own +// autosave history file, so this document is purely a placeholder for the editor area +// and never participates in Qt Creator's save infrastructure. +class ChatDocument : public Core::IDocument +{ + Q_OBJECT + +public: + explicit ChatDocument(QObject *parent = nullptr); + + QByteArray contents() const override; + Utils::Result<> setContents(const QByteArray &contents) override; + bool isModified() const override; + bool isSaveAsAllowed() const override; + bool shouldAutoSave() const override; +}; + +} // namespace QodeAssist::Chat diff --git a/chat/ChatEditor.cpp b/chat/ChatEditor.cpp new file mode 100644 index 0000000..584d991 --- /dev/null +++ b/chat/ChatEditor.cpp @@ -0,0 +1,73 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ChatEditor.hpp" + +#include +#include +#include +#include + +#include + +#include "ChatDocument.hpp" +#include "ChatView/ChatRootView.hpp" +#include "ChatView/ChatWidget.hpp" +#include "QodeAssistConstants.hpp" + +namespace QodeAssist::Chat { + +ChatEditor::ChatEditor(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry) + : m_engine(engine) + , m_sessionFileRegistry(sessionFileRegistry) + , m_document(new ChatDocument(this)) + , m_chatWidget(new ChatWidget(engine, sessionFileRegistry)) +{ + setWidget(m_chatWidget); + setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT)); + setDuplicateSupported(true); + + if (auto rootView = qobject_cast(m_chatWidget->rootObject())) { + 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(); + } + }, + Qt::QueuedConnection); + } +} + +void ChatEditor::consumePendingChatFile() +{ + if (auto rootView = qobject_cast(m_chatWidget->rootObject())) + rootView->consumePendingChatFile(); +} + +ChatEditor::~ChatEditor() +{ + delete m_chatWidget; +} + +Core::IDocument *ChatEditor::document() const +{ + return m_document; +} + +QWidget *ChatEditor::toolBar() +{ + return nullptr; +} + +Core::IEditor *ChatEditor::duplicate() +{ + return new ChatEditor(m_engine, m_sessionFileRegistry); +} + +} // namespace QodeAssist::Chat diff --git a/chat/ChatEditor.hpp b/chat/ChatEditor.hpp new file mode 100644 index 0000000..406c9e3 --- /dev/null +++ b/chat/ChatEditor.hpp @@ -0,0 +1,39 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +class QQmlEngine; + +namespace QodeAssist::Chat { + +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 + +public: + ChatEditor(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry); + ~ChatEditor() override; + + Core::IDocument *document() const override; + QWidget *toolBar() override; + Core::IEditor *duplicate() override; + + void consumePendingChatFile(); + +private: + QQmlEngine *m_engine; + SessionFileRegistry *m_sessionFileRegistry; + ChatDocument *m_document; + ChatWidget *m_chatWidget; +}; + +} // namespace QodeAssist::Chat diff --git a/chat/ChatEditorFactory.cpp b/chat/ChatEditorFactory.cpp new file mode 100644 index 0000000..f37e623 --- /dev/null +++ b/chat/ChatEditorFactory.cpp @@ -0,0 +1,20 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ChatEditorFactory.hpp" + +#include "ChatEditor.hpp" +#include "QodeAssistConstants.hpp" +#include "QodeAssisttr.h" + +namespace QodeAssist::Chat { + +ChatEditorFactory::ChatEditorFactory(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry) +{ + setId(Constants::QODE_ASSIST_CHAT_EDITOR_ID); + setDisplayName(Tr::tr("QodeAssist Chat")); + setEditorCreator( + [engine, sessionFileRegistry] { return new ChatEditor(engine, sessionFileRegistry); }); +} + +} // namespace QodeAssist::Chat diff --git a/chat/ChatEditorFactory.hpp b/chat/ChatEditorFactory.hpp new file mode 100644 index 0000000..a2d24e7 --- /dev/null +++ b/chat/ChatEditorFactory.hpp @@ -0,0 +1,20 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +class QQmlEngine; + +namespace QodeAssist::Chat { + +class SessionFileRegistry; + +class ChatEditorFactory : public Core::IEditorFactory +{ +public: + ChatEditorFactory(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry); +}; + +} // namespace QodeAssist::Chat diff --git a/chat/ChatOutputPane.cpp b/chat/ChatOutputPane.cpp index ae76955..1e58967 100644 --- a/chat/ChatOutputPane.cpp +++ b/chat/ChatOutputPane.cpp @@ -7,9 +7,10 @@ namespace QodeAssist::Chat { -ChatOutputPane::ChatOutputPane(QQmlEngine* engine, QObject *parent) +ChatOutputPane::ChatOutputPane( + QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, QObject *parent) : Core::IOutputPane(parent) - , m_chatWidget{new ChatWidget{engine}} + , m_chatWidget{new ChatWidget{engine, sessionFileRegistry}} { setId("QodeAssistChat"); setDisplayName(Tr::tr("QodeAssist Chat")); diff --git a/chat/ChatOutputPane.h b/chat/ChatOutputPane.h index d39f1be..58045a7 100644 --- a/chat/ChatOutputPane.h +++ b/chat/ChatOutputPane.h @@ -8,12 +8,17 @@ namespace QodeAssist::Chat { +class SessionFileRegistry; + class ChatOutputPane : public Core::IOutputPane { Q_OBJECT public: - explicit ChatOutputPane(QQmlEngine* engine, QObject *parent = nullptr); + explicit ChatOutputPane( + QQmlEngine *engine, + SessionFileRegistry *sessionFileRegistry, + QObject *parent = nullptr); ~ChatOutputPane() override; QWidget *outputWidget(QWidget *parent) override; diff --git a/chat/NavigationPanel.cpp b/chat/NavigationPanel.cpp index 3317707..6dc940b 100644 --- a/chat/NavigationPanel.cpp +++ b/chat/NavigationPanel.cpp @@ -4,12 +4,14 @@ #include "NavigationPanel.hpp" #include "ChatView/ChatWidget.hpp" +#include "ChatView/SessionFileRegistry.hpp" #include "QodeAssistConstants.hpp" namespace QodeAssist::Chat { -NavigationPanel::NavigationPanel(QQmlEngine* engine) +NavigationPanel::NavigationPanel(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry) : m_engine{engine} + , m_sessionFileRegistry{sessionFileRegistry} { setDisplayName(tr("QodeAssist Chat")); setPriority(500); @@ -21,7 +23,7 @@ NavigationPanel::~NavigationPanel() {} Core::NavigationView NavigationPanel::createWidget() { - return {.widget = new ChatWidget{m_engine}}; + return {.widget = new ChatWidget{m_engine, m_sessionFileRegistry}}; } } // namespace QodeAssist::Chat diff --git a/chat/NavigationPanel.hpp b/chat/NavigationPanel.hpp index 29e9ae1..78ac2dc 100644 --- a/chat/NavigationPanel.hpp +++ b/chat/NavigationPanel.hpp @@ -11,17 +11,20 @@ class QQmlEngine; namespace QodeAssist::Chat { +class SessionFileRegistry; + class NavigationPanel : public Core::INavigationWidgetFactory { Q_OBJECT public: - explicit NavigationPanel(QQmlEngine* engine); + explicit NavigationPanel(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry); ~NavigationPanel(); Core::NavigationView createWidget() override; private: QPointer m_engine; + QPointer m_sessionFileRegistry; }; } // namespace QodeAssist::Chat diff --git a/qodeassist.cpp b/qodeassist.cpp index 1ba2856..e27ba9a 100644 --- a/qodeassist.cpp +++ b/qodeassist.cpp @@ -32,6 +32,8 @@ #include "QodeAssistClient.hpp" #include "UpdateStatusWidget.hpp" #include "Version.hpp" +#include "chat/ChatEditor.hpp" +#include "chat/ChatEditorFactory.hpp" #include "chat/ChatOutputPane.h" #include "chat/NavigationPanel.hpp" #include "context/DocumentReaderQtCreator.hpp" @@ -51,7 +53,11 @@ #include "widgets/QuickRefactorDialog.hpp" #include #include +#include #include +#include +#include +#include #include #include #include @@ -86,6 +92,7 @@ public: if (m_navigationPanel) { delete m_navigationPanel; } + delete m_chatEditorFactory; } void loadTranslations() @@ -154,13 +161,15 @@ public: }); m_engine = new QQmlEngine{this}; + m_sessionFileRegistry = new Chat::SessionFileRegistry{this}; if (Settings::chatAssistantSettings().enableChatInBottomToolBar()) { - m_chatOutputPane = new Chat::ChatOutputPane{m_engine}; + m_chatOutputPane = new Chat::ChatOutputPane{m_engine, m_sessionFileRegistry}; } if (Settings::chatAssistantSettings().enableChatInNavigationPanel()) { - m_navigationPanel = new Chat::NavigationPanel{m_engine}; + m_navigationPanel = new Chat::NavigationPanel{m_engine, m_sessionFileRegistry}; } + m_chatEditorFactory = new Chat::ChatEditorFactory{m_engine, m_sessionFileRegistry}; Settings::setupProjectPanel(); ConfigurationManager::instance().init(); @@ -200,26 +209,23 @@ public: } }); - ActionBuilder showChatViewAction(this, "QodeAssist.ShowChatView"); + 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("Show QodeAssist Chat")); + showChatViewAction.setToolTip(Tr::tr("Open QodeAssist Chat in an editor split")); showChatViewAction.setText(Tr::tr("Show QodeAssist Chat")); showChatViewAction.setIcon(QCODEASSIST_CHAT_ICON.icon()); - showChatViewAction.addOnTriggered(this, [this] { - if (!m_chatView) { - m_chatView.reset(new Chat::ChatView{m_engine}); - } - - if (!m_chatView->isVisible()) { - m_chatView->show(); - } - - m_chatView->raise(); - m_chatView->requestActivate(); - }); + showChatViewAction.addOnTriggered(this, [this] { openChatInSplit(); }); m_statusWidget->setChatButtonAction(showChatViewAction.contextAction()); + m_chatButtonMenu = new QMenu(m_statusWidget); + connect( + m_chatButtonMenu, + &QMenu::aboutToShow, + this, + &QodeAssistPlugin::rebuildChatButtonMenu); + m_statusWidget->setChatButtonMenu(m_chatButtonMenu); + ActionBuilder closeChatViewAction(this, "QodeAssist.CloseChatView"); const QKeySequence closeChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_S); closeChatViewAction.setDefaultKeySequence(closeChatViewShortcut); @@ -232,6 +238,12 @@ public: } }); + ActionBuilder openChatWindowAction(this, Constants::QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION); + openChatWindowAction.setText(Tr::tr("Open QodeAssist Chat in Separate Window")); + openChatWindowAction.setToolTip(Tr::tr("Open the QodeAssist chat in a separate window")); + openChatWindowAction.setIcon(QCODEASSIST_CHAT_ICON.icon()); + openChatWindowAction.addOnTriggered(this, [this] { openChatInWindow(); }); + 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")); @@ -295,6 +307,94 @@ public: } private: + void openChatInSplit() + { + if (auto splitCommand + = Core::ActionManager::command(Core::Constants::SPLIT_SIDE_BY_SIDE)) { + if (auto splitAction = splitCommand->action()) + splitAction->trigger(); + } + QString title = Tr::tr("QodeAssist Chat"); + Core::IEditor *editor = Core::EditorManager::openEditorWithContents( + Constants::QODE_ASSIST_CHAT_EDITOR_ID, &title, {}, QUuid::createUuid().toString()); + if (auto chatEditor = qobject_cast(editor)) + chatEditor->consumePendingChatFile(); + } + + void openChatInWindow() + { + if (!m_chatView) + m_chatView.reset(new Chat::ChatView{m_engine, m_sessionFileRegistry}); + + if (!m_chatView->isVisible()) + m_chatView->show(); + + m_chatView->raise(); + m_chatView->requestActivate(); + + if (auto rootView = qobject_cast(m_chatView->rootObject())) + rootView->consumePendingChatFile(); + } + + void setChatInBottomPaneEnabled(bool enabled) + { + if (enabled && !m_chatOutputPane) + m_chatOutputPane = new Chat::ChatOutputPane{m_engine, m_sessionFileRegistry}; + else if (!enabled && m_chatOutputPane) + delete m_chatOutputPane; + + Settings::chatAssistantSettings().enableChatInBottomToolBar.setValue(enabled); + Settings::chatAssistantSettings().writeSettings(); + } + + void setChatInSidebarEnabled(bool enabled) + { + if (enabled && !m_navigationPanel) + m_navigationPanel = new Chat::NavigationPanel{m_engine, m_sessionFileRegistry}; + else if (!enabled && m_navigationPanel) + delete m_navigationPanel; + + Settings::chatAssistantSettings().enableChatInNavigationPanel.setValue(enabled); + Settings::chatAssistantSettings().writeSettings(); + } + + void rebuildChatButtonMenu() + { + if (!m_chatButtonMenu) + return; + + m_chatButtonMenu->clear(); + + QAction *paneAction = m_chatButtonMenu->addAction(Tr::tr("Chat in Bottom Panel")); + paneAction->setCheckable(true); + paneAction->setChecked(m_chatOutputPane != nullptr); + connect(paneAction, &QAction::toggled, this, [this](bool on) { + setChatInBottomPaneEnabled(on); + }); + + QAction *sidebarAction = m_chatButtonMenu->addAction(Tr::tr("Chat in Sidebar")); + sidebarAction->setCheckable(true); + sidebarAction->setChecked(m_navigationPanel != nullptr); + connect(sidebarAction, &QAction::toggled, this, [this](bool on) { + setChatInSidebarEnabled(on); + }); + + 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] { + if (m_chatView) + m_chatView->close(); + openChatInSplit(); + }); + } else { + QAction *windowAction + = m_chatButtonMenu->addAction(Tr::tr("Open Chat in Separate Window")); + connect(windowAction, &QAction::triggered, this, [this] { openChatInWindow(); }); + } + } + void checkForUpdates() { connect( @@ -321,6 +421,9 @@ private: RequestPerformanceLogger m_performanceLogger; QPointer m_chatOutputPane; QPointer m_navigationPanel; + QPointer m_sessionFileRegistry; + Chat::ChatEditorFactory *m_chatEditorFactory{nullptr}; + QPointer m_chatButtonMenu; QPointer m_updater; UpdateStatusWidget *m_statusWidget{nullptr}; QString m_lastRefactorInstructions; diff --git a/settings/ChatAssistantSettings.cpp b/settings/ChatAssistantSettings.cpp index ad32c88..7e2beb0 100644 --- a/settings/ChatAssistantSettings.cpp +++ b/settings/ChatAssistantSettings.cpp @@ -298,8 +298,6 @@ ChatAssistantSettings::ChatAssistantSettings() Column{ linkOpenFiles, autosave, - enableChatInBottomToolBar, - enableChatInNavigationPanel, Row{autoCompress, autoCompressThreshold, Stretch{1}}}}, Space{8}, Group{