Fix chat rendering and improve scroll behavior

- Emit SegmentsRole in dataChanged signals so QML updates properly
- Add onContentHeightChanged to scroll when content loads after session switch
- Add floating scroll-to-bottom button that appears when scrolled up
- Wrap chat ScrollView in Item to allow button overlay
This commit is contained in:
Levi Neely 2026-08-07 15:15:05 +02:00
parent 07232b94df
commit 0ac03a9931
2 changed files with 93 additions and 51 deletions

View File

@ -223,7 +223,7 @@ void ChatBlockModel::appendData(const QString &text)
// But emit with the rendered HTML that includes partial content // But emit with the rendered HTML that includes partial content
QModelIndex idx = index(m_blocks.size() - 1); QModelIndex idx = index(m_blocks.size() - 1);
emit dataChanged(idx, idx, {RenderedHtmlRole}); emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole});
} }
} }
@ -483,7 +483,7 @@ void ChatBlockModel::appendContent(const QString &line)
rebuildBlockHtml(m_blocks.size() - 1); rebuildBlockHtml(m_blocks.size() - 1);
QModelIndex idx = index(m_blocks.size() - 1); QModelIndex idx = index(m_blocks.size() - 1);
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole}); emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole});
} }
void ChatBlockModel::processLine(const QString &line) void ChatBlockModel::processLine(const QString &line)

View File

@ -1220,64 +1220,74 @@ ApplicationWindow {
Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor } Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor }
// Chat display — block-based ListView // Chat display — block-based ListView with scroll-to-bottom button
ScrollView { Item {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
clip: true
// Placeholder when no agent selected or disconnected ScrollView {
Label {
anchors.centerIn: parent
text: {
if (!ollie.daemonConnected) return "Not connected"
if (!ollie.activeSessionId) return "Select a session"
return "Select an agent to view chat"
}
color: theme.midColor
font.italic: true
font.pixelSize: theme.fontSize > 0 ? theme.fontSize : 12
visible: !ollie.activeAgentId || !ollie.daemonConnected
}
ListView {
id: chatView
model: chatModel
spacing: 4
anchors.fill: parent anchors.fill: parent
anchors.margins: 4 clip: true
boundsBehavior: Flickable.StopAtBounds
visible: ollie.daemonConnected && ollie.activeAgentId !== ""
// Auto-scroll when near bottom (within ~50px or at end) // Placeholder when no agent selected or disconnected
// Reset to true on new blocks, user can scroll up to disable Label {
property bool shouldAutoScroll: true anchors.centerIn: parent
readonly property bool nearBottom: contentHeight - contentY - height < 50 text: {
if (!ollie.daemonConnected) return "Not connected"
onContentYChanged: { if (!ollie.activeSessionId) return "Select a session"
// User scrolled up - disable auto-scroll return "Select an agent to view chat"
// User scrolled to bottom - re-enable
if (dragging || flicking) {
shouldAutoScroll = nearBottom || atYEnd
} }
color: theme.midColor
font.italic: true
font.pixelSize: theme.fontSize > 0 ? theme.fontSize : 12
visible: !ollie.activeAgentId || !ollie.daemonConnected
} }
onCountChanged: { ListView {
// New block added - always scroll if we were following id: chatView
if (count > 0) { model: chatModel
if (shouldAutoScroll) spacing: 4
scrollTimer.start() anchors.fill: parent
} anchors.margins: 4
} boundsBehavior: Flickable.StopAtBounds
visible: ollie.daemonConnected && ollie.activeAgentId !== ""
Connections { // Auto-scroll when near bottom (within ~50px or at end)
target: chatModel // Reset to true on new blocks, user can scroll up to disable
function onDataChanged() { property bool shouldAutoScroll: true
// Content updated (streaming) - scroll if following readonly property bool nearBottom: contentHeight - contentY - height < 50
if (chatView.shouldAutoScroll && chatView.count > 0)
scrollTimer.start() onContentYChanged: {
// User scrolled up - disable auto-scroll
// User scrolled to bottom - re-enable
if (dragging || flicking) {
shouldAutoScroll = nearBottom || atYEnd
}
}
onContentHeightChanged: {
// Content height changed (e.g., after loading) - scroll if following
if (shouldAutoScroll && count > 0) {
positionViewAtEnd()
}
}
onCountChanged: {
// New block added - always scroll if we were following
if (count > 0) {
if (shouldAutoScroll)
scrollTimer.start()
}
}
Connections {
target: chatModel
function onDataChanged() {
// Content updated (streaming) - scroll if following
if (chatView.shouldAutoScroll && chatView.count > 0)
scrollTimer.start()
}
} }
}
delegate: ColumnLayout { delegate: ColumnLayout {
id: blockItem id: blockItem
@ -1585,6 +1595,38 @@ property bool blockExpanded: {
} }
} }
} }
}
// Scroll-to-bottom floating button
Rectangle {
id: scrollToBottomBtn
width: 40
height: 40
radius: 20
color: theme.highlightColor
opacity: 0.9
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 16
visible: !chatView.shouldAutoScroll && chatView.count > 0 && chatView.visible
Label {
anchors.centerIn: parent
text: "↓"
font.pixelSize: 20
font.bold: true
color: theme.highlightedTextColor
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
chatView.shouldAutoScroll = true
chatView.positionViewAtEnd()
}
}
}
} }
Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor } Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor }