diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp index bb1c0c8..357bcc1 100644 --- a/gui/chatblockmodel.cpp +++ b/gui/chatblockmodel.cpp @@ -223,7 +223,7 @@ void ChatBlockModel::appendData(const QString &text) // But emit with the rendered HTML that includes partial content 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); 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) diff --git a/gui/main.qml b/gui/main.qml index ec14feb..b5b7071 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -1220,64 +1220,74 @@ ApplicationWindow { Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor } - // Chat display — block-based ListView - ScrollView { + // Chat display — block-based ListView with scroll-to-bottom button + Item { Layout.fillWidth: true Layout.fillHeight: true - clip: true - - // Placeholder when no agent selected or disconnected - 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 + + ScrollView { anchors.fill: parent - anchors.margins: 4 - boundsBehavior: Flickable.StopAtBounds - visible: ollie.daemonConnected && ollie.activeAgentId !== "" + clip: true - // Auto-scroll when near bottom (within ~50px or at end) - // Reset to true on new blocks, user can scroll up to disable - property bool shouldAutoScroll: true - readonly property bool nearBottom: contentHeight - contentY - height < 50 - - onContentYChanged: { - // User scrolled up - disable auto-scroll - // User scrolled to bottom - re-enable - if (dragging || flicking) { - shouldAutoScroll = nearBottom || atYEnd + // Placeholder when no agent selected or disconnected + 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 } - onCountChanged: { - // New block added - always scroll if we were following - if (count > 0) { - if (shouldAutoScroll) - scrollTimer.start() - } - } + ListView { + id: chatView + model: chatModel + spacing: 4 + anchors.fill: parent + anchors.margins: 4 + boundsBehavior: Flickable.StopAtBounds + visible: ollie.daemonConnected && ollie.activeAgentId !== "" - Connections { - target: chatModel - function onDataChanged() { - // Content updated (streaming) - scroll if following - if (chatView.shouldAutoScroll && chatView.count > 0) - scrollTimer.start() + // Auto-scroll when near bottom (within ~50px or at end) + // Reset to true on new blocks, user can scroll up to disable + property bool shouldAutoScroll: true + readonly property bool nearBottom: contentHeight - contentY - height < 50 + + 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 { 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 }