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)
else }
userIndices = []
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
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,70 +98,90 @@ 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() }
} }
Rectangle { Flickable {
id: spine id: flick
visible: nav.userIndices.length > 1 anchors.fill: parent
x: nav.width / 2 - width / 2 contentWidth: width
y: 14 contentHeight: nav.contentHeight
width: 1 interactive: nav.needsScrolling
height: Math.max(0, nav.height - 28) clip: true
color: palette.mid boundsBehavior: Flickable.StopAtBounds
opacity: 0.4
}
Repeater { Rectangle {
model: nav.userIndices id: spine
delegate: Item { visible: nav.dotCount > 1
id: dotItem anchors.horizontalCenter: parent.horizontalCenter
y: nav.verticalPadding
width: 1
height: Math.max(0, flick.contentHeight - 2 * nav.verticalPadding)
color: palette.mid
opacity: 0.4
}
required property var modelData Repeater {
required property int index model: nav.entries
width: nav.width delegate: Item {
height: 14 id: dotItem
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 { required property var modelData
id: dot required property int index
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 } } readonly property int msgIndex: modelData && modelData.messageIndex !== undefined
Behavior on color { ColorAnimation { duration: 120 } } ? modelData.messageIndex : -1
} readonly property string preview: modelData && modelData.preview !== undefined
? modelData.preview : ""
readonly property bool isCurrent: nav.currentMessageIndex === msgIndex
MouseArea { width: 16
id: dotArea height: 14
anchors.horizontalCenter: parent.horizontalCenter
y: nav.dotCenterY(index) - height / 2
anchors.fill: parent Rectangle {
hoverEnabled: true id: dot
cursorShape: Qt.PointingHandCursor
onClicked: nav.messageClicked(dotItem.modelData)
ToolTip.visible: containsMouse anchors.centerIn: parent
ToolTip.delay: 400 width: dotItem.isCurrent ? 11 : (dotArea.containsMouse ? 10 : 7)
ToolTip.text: qsTr("Jump to message #%1").arg(dotItem.index + 1) height: width
radius: width / 2
color: dotArea.containsMouse
? Qt.lighter(nav.dotColor, 1.2)
: nav.dotColor
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 opacity { NumberAnimation { duration: 120 } }
Behavior on color { ColorAnimation { duration: 120 } }
}
MouseArea {
id: dotArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: nav.messageClicked(dotItem.msgIndex)
QoAToolTip {
visible: dotArea.containsMouse
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)
}
}
} }
} }
} }
} }