diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index 6f0ab14..f759cdc 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -27,6 +27,7 @@ qt_add_qml_module(QodeAssistChatView qml/controls/Toast.qml qml/controls/TopBar.qml qml/controls/SplitDropZone.qml + qml/controls/MessageNavigator.qml RESOURCES icons/attach-file-light.svg diff --git a/ChatView/ChatModel.cpp b/ChatView/ChatModel.cpp index dbe316c..218ee7d 100644 --- a/ChatView/ChatModel.cpp +++ b/ChatView/ChatModel.cpp @@ -335,6 +335,16 @@ void ChatModel::resetModelTo(int index) } } +QVariantList ChatModel::userMessageIndices() const +{ + QVariantList result; + for (int i = 0; i < m_messages.size(); ++i) { + if (m_messages[i].role == ChatRole::User) + result.append(i); + } + return result; +} + void ChatModel::addToolExecutionStatus( const QString &requestId, const QString &toolId, diff --git a/ChatView/ChatModel.hpp b/ChatView/ChatModel.hpp index ad4f02e..a8acc5f 100644 --- a/ChatView/ChatModel.hpp +++ b/ChatView/ChatModel.hpp @@ -94,6 +94,7 @@ public: QString lastMessageId() const; Q_INVOKABLE void resetModelTo(int index); + Q_INVOKABLE QVariantList userMessageIndices() const; void addToolExecutionStatus( const QString &requestId, diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index 14290e5..3a34766 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -175,6 +175,27 @@ ChatRootView { } } + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 2 + + MessageNavigator { + id: messageNavigator + + Layout.preferredWidth: 16 + Layout.fillHeight: true + Layout.topMargin: 4 + Layout.bottomMargin: 4 + + chatModel: root.chatModel + + onMessageClicked: function(messageIndex) { + chatListView.userScrolledUp = true + chatListView.positionViewAtIndex(messageIndex, ListView.Beginning) + } + } + ListView { id: chatListView @@ -182,7 +203,7 @@ ChatRootView { Layout.fillWidth: true Layout.fillHeight: true - leftMargin: 5 + leftMargin: 3 model: root.chatModel clip: true spacing: 0 @@ -370,6 +391,7 @@ ChatRootView { } } } + } ScrollView { id: view diff --git a/ChatView/qml/controls/MessageNavigator.qml b/ChatView/qml/controls/MessageNavigator.qml new file mode 100644 index 0000000..a559375 --- /dev/null +++ b/ChatView/qml/controls/MessageNavigator.qml @@ -0,0 +1,103 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Controls +import ChatView + +Item { + id: nav + + property var chatModel + property var userIndices: [] + property int hoveredDotIndex: -1 + property color dotColor: "#92BD6C" + + signal messageClicked(int messageIndex) + + implicitWidth: 16 + + function rebuild() { + if (chatModel) + userIndices = chatModel.userMessageIndices() + else + userIndices = [] + } + + onChatModelChanged: rebuild() + Component.onCompleted: rebuild() + + Connections { + target: nav.chatModel + ignoreUnknownSignals: true + function onRowsInserted() { nav.rebuild() } + function onRowsRemoved() { nav.rebuild() } + function onModelReset() { nav.rebuild() } + function onModelReseted() { nav.rebuild() } + function onLayoutChanged() { nav.rebuild() } + } + + Rectangle { + id: spine + + visible: nav.userIndices.length > 1 + x: nav.width / 2 - width / 2 + y: 14 + width: 1 + height: Math.max(0, nav.height - 28) + color: palette.mid + opacity: 0.4 + } + + Repeater { + model: nav.userIndices + + delegate: Item { + id: dotItem + + required property var modelData + required property int index + + width: nav.width + height: 14 + x: 0 + y: { + const count = nav.userIndices.length + const dotH = height + if (count <= 1) + return (nav.height - dotH) / 2 + const top = 7 + const bottom = nav.height - dotH - 7 + return top + (bottom - top) * index / (count - 1) + } + + Rectangle { + id: dot + anchors.centerIn: parent + width: dotArea.containsMouse ? 10 : 7 + height: width + radius: width / 2 + color: dotArea.containsMouse ? Qt.lighter(nav.dotColor, 1.15) : nav.dotColor + border.color: Qt.darker(nav.dotColor, 1.4) + border.width: 1 + opacity: dotArea.containsMouse ? 1.0 : 0.9 + + Behavior on width { NumberAnimation { duration: 120 } } + Behavior on color { ColorAnimation { duration: 120 } } + } + + MouseArea { + id: dotArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: nav.messageClicked(dotItem.modelData) + + ToolTip.visible: containsMouse + ToolTip.delay: 400 + ToolTip.text: qsTr("Jump to message #%1").arg(dotItem.index + 1) + } + } + } +}