feat: Add preview to message navigator

This commit is contained in:
Petr Mironychev
2026-05-27 23:03:10 +02:00
parent 66e25300e8
commit af898bd255
4 changed files with 165 additions and 61 deletions

View File

@@ -335,12 +335,24 @@ void ChatModel::resetModelTo(int index)
} }
} }
QVariantList ChatModel::userMessageIndices() const QVariantList ChatModel::userMessagePreviews(int maxLength) const
{ {
QVariantList result; QVariantList result;
const int limit = maxLength > 4 ? maxLength : 80;
for (int i = 0; i < m_messages.size(); ++i) { for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].role == ChatRole::User) if (m_messages[i].role != ChatRole::User)
result.append(i); continue;
QString preview = m_messages[i].content;
preview.replace(QLatin1Char('\n'), QLatin1Char(' '));
preview.replace(QLatin1Char('\r'), QLatin1Char(' '));
preview.replace(QLatin1Char('\t'), QLatin1Char(' '));
preview = preview.simplified();
if (preview.size() > limit)
preview = preview.left(limit - 1).trimmed() + QChar(0x2026);
QVariantMap entry;
entry[QStringLiteral("messageIndex")] = i;
entry[QStringLiteral("preview")] = preview;
result.append(entry);
} }
return result; return result;
} }

View File

@@ -94,7 +94,7 @@ public:
QString lastMessageId() const; QString lastMessageId() const;
Q_INVOKABLE void resetModelTo(int index); Q_INVOKABLE void resetModelTo(int index);
Q_INVOKABLE QVariantList userMessageIndices() const; Q_INVOKABLE QVariantList userMessagePreviews(int maxLength = 80) const;
void addToolExecutionStatus( void addToolExecutionStatus(
const QString &requestId, const QString &requestId,

View File

@@ -201,6 +201,11 @@ ChatRootView {
property bool userScrolledUp: false property bool userScrolledUp: false
function syncNavigatorCurrent() {
const top = indexAt(10, contentY + 4)
messageNavigator.updateCurrentFromModelIndex(top)
}
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
leftMargin: 3 leftMargin: 3
@@ -210,6 +215,8 @@ ChatRootView {
boundsBehavior: Flickable.StopAtBounds boundsBehavior: Flickable.StopAtBounds
cacheBuffer: 2000 cacheBuffer: 2000
onContentYChanged: Qt.callLater(syncNavigatorCurrent)
onMovingChanged: { onMovingChanged: {
if (moving) { if (moving) {
userScrolledUp = !atYEnd userScrolledUp = !atYEnd
@@ -296,6 +303,7 @@ ChatRootView {
if (!userScrolledUp) { if (!userScrolledUp) {
root.scrollToBottom() root.scrollToBottom()
} }
Qt.callLater(syncNavigatorCurrent)
} }
onContentHeightChanged: { onContentHeightChanged: {

View File

@@ -4,27 +4,91 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import ChatView import ChatView
import UIControls
Item { Item {
id: nav id: nav
property var chatModel property var chatModel
property var userIndices: [] property var entries: []
property int hoveredDotIndex: -1
property color dotColor: "#92BD6C" property color dotColor: "#92BD6C"
property int currentMessageIndex: -1
readonly property int dotCount: entries.length
readonly property int verticalPadding: 8
readonly property int minDotSpacing: 18
readonly property real availableHeight: Math.max(0, height - 2 * verticalPadding)
readonly property real naturalHeight: dotCount > 1 ? (dotCount - 1) * minDotSpacing : 0
readonly property bool needsScrolling: naturalHeight > availableHeight
readonly property real contentHeight: needsScrolling
? naturalHeight + 2 * verticalPadding
: Math.max(height, 2 * verticalPadding)
signal messageClicked(int messageIndex) signal messageClicked(int messageIndex)
implicitWidth: 16 implicitWidth: 16
function rebuild() { function rebuild() {
if (chatModel) entries = chatModel ? chatModel.userMessagePreviews(80) : []
userIndices = chatModel.userMessageIndices() Qt.callLater(scrollCurrentIntoView)
}
function updateCurrentFromModelIndex(modelIdx) {
if (modelIdx < 0) {
currentMessageIndex = -1
return
}
let best = -1
for (let i = 0; i < entries.length; ++i) {
const e = entries[i]
if (!e)
continue
const mi = e.messageIndex
if (mi <= modelIdx)
best = mi
else else
userIndices = [] break
}
currentMessageIndex = best
}
function uiIndexOf(messageIndex) {
for (let i = 0; i < entries.length; ++i) {
const e = entries[i]
if (e && e.messageIndex === messageIndex)
return i
}
return -1
}
function dotCenterY(uiIndex) {
const count = dotCount
if (count <= 1)
return contentHeight / 2
const spacing = needsScrolling
? minDotSpacing
: availableHeight / (count - 1)
return verticalPadding + spacing * uiIndex
}
function scrollCurrentIntoView() {
if (!needsScrolling || currentMessageIndex < 0)
return
const ui = uiIndexOf(currentMessageIndex)
if (ui < 0)
return
const y = dotCenterY(ui)
const margin = 24
if (y < flick.contentY + margin)
flick.contentY = Math.max(0, y - margin)
else if (y > flick.contentY + flick.height - margin)
flick.contentY = Math.min(
Math.max(0, flick.contentHeight - flick.height),
y - flick.height + margin)
} }
onChatModelChanged: rebuild() onChatModelChanged: rebuild()
onCurrentMessageIndexChanged: scrollCurrentIntoView()
Component.onCompleted: rebuild() Component.onCompleted: rebuild()
Connections { Connections {
@@ -34,23 +98,33 @@ Item {
function onRowsRemoved() { nav.rebuild() } function onRowsRemoved() { nav.rebuild() }
function onModelReset() { nav.rebuild() } function onModelReset() { nav.rebuild() }
function onModelReseted() { nav.rebuild() } function onModelReseted() { nav.rebuild() }
function onLayoutChanged() { nav.rebuild() } function onDataChanged() { nav.rebuild() }
} }
Flickable {
id: flick
anchors.fill: parent
contentWidth: width
contentHeight: nav.contentHeight
interactive: nav.needsScrolling
clip: true
boundsBehavior: Flickable.StopAtBounds
Rectangle { Rectangle {
id: spine id: spine
visible: nav.userIndices.length > 1 visible: nav.dotCount > 1
x: nav.width / 2 - width / 2 anchors.horizontalCenter: parent.horizontalCenter
y: 14 y: nav.verticalPadding
width: 1 width: 1
height: Math.max(0, nav.height - 28) height: Math.max(0, flick.contentHeight - 2 * nav.verticalPadding)
color: palette.mid color: palette.mid
opacity: 0.4 opacity: 0.4
} }
Repeater { Repeater {
model: nav.userIndices model: nav.entries
delegate: Item { delegate: Item {
id: dotItem id: dotItem
@@ -58,31 +132,35 @@ Item {
required property var modelData required property var modelData
required property int index required property int index
width: nav.width readonly property int msgIndex: modelData && modelData.messageIndex !== undefined
? modelData.messageIndex : -1
readonly property string preview: modelData && modelData.preview !== undefined
? modelData.preview : ""
readonly property bool isCurrent: nav.currentMessageIndex === msgIndex
width: 16
height: 14 height: 14
x: 0 anchors.horizontalCenter: parent.horizontalCenter
y: { y: nav.dotCenterY(index) - height / 2
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 { Rectangle {
id: dot id: dot
anchors.centerIn: parent anchors.centerIn: parent
width: dotArea.containsMouse ? 10 : 7 width: dotItem.isCurrent ? 11 : (dotArea.containsMouse ? 10 : 7)
height: width height: width
radius: width / 2 radius: width / 2
color: dotArea.containsMouse ? Qt.lighter(nav.dotColor, 1.15) : nav.dotColor color: dotArea.containsMouse
border.color: Qt.darker(nav.dotColor, 1.4) ? Qt.lighter(nav.dotColor, 1.2)
border.width: 1 : nav.dotColor
opacity: dotArea.containsMouse ? 1.0 : 0.9 border.color: dotItem.isCurrent
? Qt.darker(nav.dotColor, 1.7)
: Qt.darker(nav.dotColor, 1.4)
border.width: dotItem.isCurrent ? 2 : 1
opacity: dotItem.isCurrent || dotArea.containsMouse ? 1.0 : 0.55
Behavior on width { NumberAnimation { duration: 120 } } Behavior on width { NumberAnimation { duration: 120 } }
Behavior on opacity { NumberAnimation { duration: 120 } }
Behavior on color { ColorAnimation { duration: 120 } } Behavior on color { ColorAnimation { duration: 120 } }
} }
@@ -92,12 +170,18 @@ Item {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: nav.messageClicked(dotItem.modelData) onClicked: nav.messageClicked(dotItem.msgIndex)
ToolTip.visible: containsMouse QoAToolTip {
ToolTip.delay: 400 visible: dotArea.containsMouse
ToolTip.text: qsTr("Jump to message #%1").arg(dotItem.index + 1) delay: 350
text: dotItem.preview.length > 0
? qsTr("#%1 · %2").arg(dotItem.index + 1).arg(dotItem.preview)
: qsTr("Jump to message #%1").arg(dotItem.index + 1)
} }
} }
} }
}
}
} }