Skip to content
This repository was archived by the owner on Aug 31, 2026. It is now read-only.

Commit af898bd

Browse files
committed
feat: Add preview to message navigator
1 parent 66e2530 commit af898bd

4 files changed

Lines changed: 174 additions & 70 deletions

File tree

ChatView/ChatModel.cpp

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -335,12 +335,24 @@ void ChatModel::resetModelTo(int index)
335335
}
336336
}
337337

338-
QVariantList ChatModel::userMessageIndices() const
338+
QVariantList ChatModel::userMessagePreviews(int maxLength) const
339339
{
340340
QVariantList result;
341+
const int limit = maxLength > 4 ? maxLength : 80;
341342
for (int i = 0; i < m_messages.size(); ++i) {
342-
if (m_messages[i].role == ChatRole::User)
343-
result.append(i);
343+
if (m_messages[i].role != ChatRole::User)
344+
continue;
345+
QString preview = m_messages[i].content;
346+
preview.replace(QLatin1Char('\n'), QLatin1Char(' '));
347+
preview.replace(QLatin1Char('\r'), QLatin1Char(' '));
348+
preview.replace(QLatin1Char('\t'), QLatin1Char(' '));
349+
preview = preview.simplified();
350+
if (preview.size() > limit)
351+
preview = preview.left(limit - 1).trimmed() + QChar(0x2026);
352+
QVariantMap entry;
353+
entry[QStringLiteral("messageIndex")] = i;
354+
entry[QStringLiteral("preview")] = preview;
355+
result.append(entry);
344356
}
345357
return result;
346358
}

ChatView/ChatModel.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ class ChatModel : public QAbstractListModel
9494
QString lastMessageId() const;
9595

9696
Q_INVOKABLE void resetModelTo(int index);
97-
Q_INVOKABLE QVariantList userMessageIndices() const;
97+
Q_INVOKABLE QVariantList userMessagePreviews(int maxLength = 80) const;
9898

9999
void addToolExecutionStatus(
100100
const QString &requestId,

ChatView/qml/RootItem.qml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,11 @@ ChatRootView {
201201

202202
property bool userScrolledUp: false
203203

204+
function syncNavigatorCurrent() {
205+
const top = indexAt(10, contentY + 4)
206+
messageNavigator.updateCurrentFromModelIndex(top)
207+
}
208+
204209
Layout.fillWidth: true
205210
Layout.fillHeight: true
206211
leftMargin: 3
@@ -210,6 +215,8 @@ ChatRootView {
210215
boundsBehavior: Flickable.StopAtBounds
211216
cacheBuffer: 2000
212217

218+
onContentYChanged: Qt.callLater(syncNavigatorCurrent)
219+
213220
onMovingChanged: {
214221
if (moving) {
215222
userScrolledUp = !atYEnd
@@ -296,6 +303,7 @@ ChatRootView {
296303
if (!userScrolledUp) {
297304
root.scrollToBottom()
298305
}
306+
Qt.callLater(syncNavigatorCurrent)
299307
}
300308

301309
onContentHeightChanged: {

ChatView/qml/controls/MessageNavigator.qml

Lines changed: 150 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,91 @@
44
import QtQuick
55
import QtQuick.Controls
66
import ChatView
7+
import UIControls
78

89
Item {
910
id: nav
1011

1112
property var chatModel
12-
property var userIndices: []
13-
property int hoveredDotIndex: -1
13+
property var entries: []
1414
property color dotColor: "#92BD6C"
15+
property int currentMessageIndex: -1
16+
17+
readonly property int dotCount: entries.length
18+
readonly property int verticalPadding: 8
19+
readonly property int minDotSpacing: 18
20+
readonly property real availableHeight: Math.max(0, height - 2 * verticalPadding)
21+
readonly property real naturalHeight: dotCount > 1 ? (dotCount - 1) * minDotSpacing : 0
22+
readonly property bool needsScrolling: naturalHeight > availableHeight
23+
readonly property real contentHeight: needsScrolling
24+
? naturalHeight + 2 * verticalPadding
25+
: Math.max(height, 2 * verticalPadding)
1526

1627
signal messageClicked(int messageIndex)
1728

1829
implicitWidth: 16
1930

2031
function rebuild() {
21-
if (chatModel)
22-
userIndices = chatModel.userMessageIndices()
23-
else
24-
userIndices = []
32+
entries = chatModel ? chatModel.userMessagePreviews(80) : []
33+
Qt.callLater(scrollCurrentIntoView)
34+
}
35+
36+
function updateCurrentFromModelIndex(modelIdx) {
37+
if (modelIdx < 0) {
38+
currentMessageIndex = -1
39+
return
40+
}
41+
let best = -1
42+
for (let i = 0; i < entries.length; ++i) {
43+
const e = entries[i]
44+
if (!e)
45+
continue
46+
const mi = e.messageIndex
47+
if (mi <= modelIdx)
48+
best = mi
49+
else
50+
break
51+
}
52+
currentMessageIndex = best
53+
}
54+
55+
function uiIndexOf(messageIndex) {
56+
for (let i = 0; i < entries.length; ++i) {
57+
const e = entries[i]
58+
if (e && e.messageIndex === messageIndex)
59+
return i
60+
}
61+
return -1
62+
}
63+
64+
function dotCenterY(uiIndex) {
65+
const count = dotCount
66+
if (count <= 1)
67+
return contentHeight / 2
68+
const spacing = needsScrolling
69+
? minDotSpacing
70+
: availableHeight / (count - 1)
71+
return verticalPadding + spacing * uiIndex
72+
}
73+
74+
function scrollCurrentIntoView() {
75+
if (!needsScrolling || currentMessageIndex < 0)
76+
return
77+
const ui = uiIndexOf(currentMessageIndex)
78+
if (ui < 0)
79+
return
80+
const y = dotCenterY(ui)
81+
const margin = 24
82+
if (y < flick.contentY + margin)
83+
flick.contentY = Math.max(0, y - margin)
84+
else if (y > flick.contentY + flick.height - margin)
85+
flick.contentY = Math.min(
86+
Math.max(0, flick.contentHeight - flick.height),
87+
y - flick.height + margin)
2588
}
2689

2790
onChatModelChanged: rebuild()
91+
onCurrentMessageIndexChanged: scrollCurrentIntoView()
2892
Component.onCompleted: rebuild()
2993

3094
Connections {
@@ -34,70 +98,90 @@ Item {
3498
function onRowsRemoved() { nav.rebuild() }
3599
function onModelReset() { nav.rebuild() }
36100
function onModelReseted() { nav.rebuild() }
37-
function onLayoutChanged() { nav.rebuild() }
101+
function onDataChanged() { nav.rebuild() }
38102
}
39103

40-
Rectangle {
41-
id: spine
42-
43-
visible: nav.userIndices.length > 1
44-
x: nav.width / 2 - width / 2
45-
y: 14
46-
width: 1
47-
height: Math.max(0, nav.height - 28)
48-
color: palette.mid
49-
opacity: 0.4
50-
}
51-
52-
Repeater {
53-
model: nav.userIndices
54-
55-
delegate: Item {
56-
id: dotItem
57-
58-
required property var modelData
59-
required property int index
60-
61-
width: nav.width
62-
height: 14
63-
x: 0
64-
y: {
65-
const count = nav.userIndices.length
66-
const dotH = height
67-
if (count <= 1)
68-
return (nav.height - dotH) / 2
69-
const top = 7
70-
const bottom = nav.height - dotH - 7
71-
return top + (bottom - top) * index / (count - 1)
72-
}
73-
74-
Rectangle {
75-
id: dot
76-
anchors.centerIn: parent
77-
width: dotArea.containsMouse ? 10 : 7
78-
height: width
79-
radius: width / 2
80-
color: dotArea.containsMouse ? Qt.lighter(nav.dotColor, 1.15) : nav.dotColor
81-
border.color: Qt.darker(nav.dotColor, 1.4)
82-
border.width: 1
83-
opacity: dotArea.containsMouse ? 1.0 : 0.9
84-
85-
Behavior on width { NumberAnimation { duration: 120 } }
86-
Behavior on color { ColorAnimation { duration: 120 } }
87-
}
88-
89-
MouseArea {
90-
id: dotArea
91-
92-
anchors.fill: parent
93-
hoverEnabled: true
94-
cursorShape: Qt.PointingHandCursor
95-
onClicked: nav.messageClicked(dotItem.modelData)
104+
Flickable {
105+
id: flick
106+
107+
anchors.fill: parent
108+
contentWidth: width
109+
contentHeight: nav.contentHeight
110+
interactive: nav.needsScrolling
111+
clip: true
112+
boundsBehavior: Flickable.StopAtBounds
113+
114+
Rectangle {
115+
id: spine
116+
117+
visible: nav.dotCount > 1
118+
anchors.horizontalCenter: parent.horizontalCenter
119+
y: nav.verticalPadding
120+
width: 1
121+
height: Math.max(0, flick.contentHeight - 2 * nav.verticalPadding)
122+
color: palette.mid
123+
opacity: 0.4
124+
}
96125

97-
ToolTip.visible: containsMouse
98-
ToolTip.delay: 400
99-
ToolTip.text: qsTr("Jump to message #%1").arg(dotItem.index + 1)
126+
Repeater {
127+
model: nav.entries
128+
129+
delegate: Item {
130+
id: dotItem
131+
132+
required property var modelData
133+
required property int index
134+
135+
readonly property int msgIndex: modelData && modelData.messageIndex !== undefined
136+
? modelData.messageIndex : -1
137+
readonly property string preview: modelData && modelData.preview !== undefined
138+
? modelData.preview : ""
139+
readonly property bool isCurrent: nav.currentMessageIndex === msgIndex
140+
141+
width: 16
142+
height: 14
143+
anchors.horizontalCenter: parent.horizontalCenter
144+
y: nav.dotCenterY(index) - height / 2
145+
146+
Rectangle {
147+
id: dot
148+
149+
anchors.centerIn: parent
150+
width: dotItem.isCurrent ? 11 : (dotArea.containsMouse ? 10 : 7)
151+
height: width
152+
radius: width / 2
153+
color: dotArea.containsMouse
154+
? Qt.lighter(nav.dotColor, 1.2)
155+
: nav.dotColor
156+
border.color: dotItem.isCurrent
157+
? Qt.darker(nav.dotColor, 1.7)
158+
: Qt.darker(nav.dotColor, 1.4)
159+
border.width: dotItem.isCurrent ? 2 : 1
160+
opacity: dotItem.isCurrent || dotArea.containsMouse ? 1.0 : 0.55
161+
162+
Behavior on width { NumberAnimation { duration: 120 } }
163+
Behavior on opacity { NumberAnimation { duration: 120 } }
164+
Behavior on color { ColorAnimation { duration: 120 } }
165+
}
166+
167+
MouseArea {
168+
id: dotArea
169+
170+
anchors.fill: parent
171+
hoverEnabled: true
172+
cursorShape: Qt.PointingHandCursor
173+
onClicked: nav.messageClicked(dotItem.msgIndex)
174+
175+
QoAToolTip {
176+
visible: dotArea.containsMouse
177+
delay: 350
178+
text: dotItem.preview.length > 0
179+
? qsTr("#%1 · %2").arg(dotItem.index + 1).arg(dotItem.preview)
180+
: qsTr("Jump to message #%1").arg(dotItem.index + 1)
181+
}
182+
}
100183
}
101184
}
102185
}
186+
103187
}

0 commit comments

Comments
 (0)