diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 4878c9d..dfb647a 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -1,7 +1,8 @@ -import ZShell.Config -import ZShell.Llm import QtQuick import QtQuick.Layouts +import ZShell.Components +import ZShell.Config +import ZShell.Llm import qs.Modules.Notifications.Sidebar.Chat.Content import qs.Components import qs.Services @@ -15,7 +16,7 @@ Item { signal requestClose function scrollToBottom(): void { - Qt.callLater(list.positionViewAtBeginning); + Qt.callLater(() => listLoader.item?.positionViewAtBeginning()); } function send(text: string): void { @@ -26,8 +27,6 @@ Item { input.text = ""; } - Component.onCompleted: input.focus = true - RowLayout { id: header @@ -54,10 +53,8 @@ Item { } } - VerticalFadeListView { - id: list - - property bool userScrolledUp: false + Loader { + id: listLoader anchors.bottom: input.top anchors.bottomMargin: Tokens.spacing.medium @@ -65,58 +62,83 @@ Item { anchors.right: parent.right anchors.top: header.bottom anchors.topMargin: Tokens.spacing.medium - cacheBuffer: height * 20 - clip: true - fadeAmount: 0.05 - fadeThreshold: Tokens.padding.medium - model: root.chatData.messagesModel - spacing: 0 - verticalLayoutDirection: VerticalFadeListView.BottomToTop + active: root.chatData && root.chatData.loaded - add: Transition { - Anim { - from: list.width - property: "x" - to: 0 + sourceComponent: ListView { + id: list + + property bool userScrolledUp: false + + function scrollToBottom(): void { + Qt.callLater(() => positionViewAtBeginning()); } - } - delegate: MessageDelegate {} - displaced: Transition { - Anim { - property: "y" + + cacheBuffer: height * 20 + clip: true + // fadeAmount: 0.05 + // fadeThreshold: Tokens.padding.medium + model: root.chatData.messagesModel + spacing: 0 + rotation: 180 + + // add: Transition { + // Anim { + // from: 10 + // property: "y" + // } + // } + delegate: MessageDelegate { + rotation: 180 } - } - move: Transition { + + // displaced: Transition { + // Anim { + // property: "y" + // } + // } + // move: Transition { + // Anim { + // property: "y" + // } + // } + + Component.onCompleted: { + positionViewAtBeginning(); + forceActiveFocus(); + } + onAtYEndChanged: { + if (atYEnd) + userScrolledUp = false; + } + onContentHeightChanged: { + if (!userScrolledUp && atYEnd) + scrollToBottom(); + } + onCountChanged: { + if (!userScrolledUp) + scrollToBottom(); + } + onMovingChanged: { + if (moving) + userScrolledUp = !atYEnd; + } + Anim { - property: "y" + id: scrollAnim + + property: "contentY" + target: list + type: Anim.DefaultEffects + } + + WheelInverter { + target: list } } - Component.onCompleted: positionViewAtBeginning() - onAtYEndChanged: { - if (atYEnd) - userScrolledUp = false; - } - onContentHeightChanged: { - if (!userScrolledUp && atYEnd) - root.scrollToBottom(); - } - onCountChanged: { - if (!userScrolledUp) - root.scrollToBottom(); - } - onMovingChanged: { - if (moving) - userScrolledUp = !atYEnd; - } - - Anim { - id: scrollAnim - - property: "contentY" - target: list - type: Anim.DefaultEffects - } + // Trigger the load even before we're ready to show the list. + Component.onCompleted: if (root.chatData) + root.chatData.messagesModel } EmptyBackground { @@ -133,7 +155,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter implicitHeight: scrollToBottom.implicitHeight implicitWidth: scrollToBottom.implicitWidth - scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0 + scale: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0 visible: scale > 0 Behavior on scale { @@ -157,7 +179,7 @@ Item { type: IconButton.Tonal onClicked: { - list.positionViewAtBeginning(); + listLoader.item.positionViewAtBeginning(); } } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml index 61b40fa..5e93159 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -33,6 +33,12 @@ Item { implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2 anchors.right: root.isUser ? parent.right : undefined + Behavior on implicitHeight { + // enabled: root.segment.running + + Anim {} + } + // User messages stay a plain editable text field. TextEditBase { id: msgText @@ -103,12 +109,13 @@ Item { Actions { id: actionsRow + readonly property bool shouldBeActive: (root.segment.type === LlmSegment.Type.Content) && !root.segment.running && root.repeater.count === (root.index + 1) + anchors.left: parent.left anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right anchors.top: bubble.bottom - visible: { - return (root.segment.type === LlmSegment.Type.Content) && root.segment.status !== LlmSegment.Status.Running && root.repeater.count === (root.index + 1); - } + opacity: shouldBeActive ? 1 : 0 + visible: opacity > 0 anchors.topMargin: Tokens.spacing.medium edit: msgText hovered: root.hovered @@ -116,5 +123,9 @@ Item { segment: root.segment current: root.current message: root.message + + Behavior on opacity { + Anim {} + } } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index 0133a1e..d69f109 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -11,7 +11,7 @@ import qs.Services MouseArea { id: root - readonly property ChatGeneration current: modelData.activeGeneration + property ChatGeneration current: modelData.activeGeneration required property int index readonly property bool isUser: modelData.role === ChatMessage.Role.User required property ChatMessage modelData @@ -60,22 +60,79 @@ MouseArea { } } } + return blocks; } implicitWidth: ListView.view.width hoverEnabled: true - preventStealing: true + preventStealing: false implicitHeight: layout.implicitHeight + Tokens.padding.medium - Binding { - property: "contentY" - restoreMode: Binding.RestoreNone - target: root.ListView.view - value: root.y - Tokens.padding.large * 2 - when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY) + Behavior on current { + id: changeAnim + + property var object: targetProperty + readonly property bool next: { + const cond = root.modelData.generations.indexOf(current) < root.modelData.generations.indexOf(changeAnim.targetValue); + return cond; + } + + animation: SequentialAnimation { + ParallelAnimation { + Anim { + target: root + property: "x" + to: changeAnim.next ? -root.width / 2 : root.width / 2 + from: 0 + } + + Anim { + target: root + property: "opacity" + from: 1 + to: 0 + } + } + + PropertyAction {} + + ParallelAnimation { + Anim { + target: root + property: "x" + from: changeAnim.next ? root.width / 2 : -root.width / 2 + to: 0 + } + + Anim { + target: root + property: "opacity" + from: 0 + to: 1 + } + } + } } + // Behavior on implicitHeight { + // enabled: !root.isUser && root.current.streaming + // + // Anim {} + // } + + onCurrentChanged: { + console.log(modelData.generations.indexOf(current)); + } + + // Binding { + // property: "contentY" + // restoreMode: Binding.RestoreNone + // target: root.ListView.view + // value: root.y - Tokens.padding.large * 2 + // when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY) + // } + Anim { id: restoreAnim @@ -91,6 +148,20 @@ MouseArea { anchors.left: parent.left anchors.right: parent.right + // onImplicitHeightChanged: console.log("LAYOUT:", layout.implicitHeight) + + // add: Transition { + // Anim { + // from: 10 + // property: "y" + // } + // } + // move: Transition { + // Anim { + // property: "y" + // } + // } + Repeater { id: segmentRep @@ -105,12 +176,8 @@ MouseArea { roleValue: "process" delegate: ProcessBlock { - required property int index - required property var modelData - - segments: modelData.segments - isActive: index === root.blocks.length - 1 width: root.width + blocks: root.blocks onExpandedChanged: root.handleReasoningToggle(expanded) } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml index 18f7c85..c681bfb 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -5,92 +5,106 @@ import ZShell.Llm import qs.Components import qs.Services -Item { +CustomClippingRect { id: root - required property var segments - required property bool isActive + required property int index + required property var modelData + required property var blocks + readonly property var segments: modelData.segments + readonly property bool isActive: index === blocks.length - 1 property bool expanded: false readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) - implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin + implicitHeight: expanded ? expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin * 2 : layout.implicitHeight + Tokens.spacing.small - LoadingIndicator { - id: spinnerReasoning - - anchors.centerIn: expandBtn - implicitSize: collapsedText.implicitHeight - opacity: root.isActive ? 1 : 0 - visible: opacity > 0 - - Behavior on opacity { - Anim {} + Behavior on implicitHeight { + Anim { + type: Anim.DefaultEffects } } - IconButton { - id: expandBtn + RowLayout { + id: layout - anchors.left: parent.left - anchors.verticalCenter: collapsedText.verticalCenter - font.pointSize: Tokens.font.size.large - anchors.topMargin: 0 - icon: "keyboard_arrow_down" - inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline - opacity: root.isActive ? 0 : 1 - rotation: root.expanded ? 180 : 0 - type: IconButton.Text - - Behavior on rotation { - Anim {} - } - - onClicked: root.expanded = !root.expanded - } - - CustomText { - id: collapsedText - - anchors.left: expandBtn.right - anchors.margins: Tokens.padding.medium - anchors.right: parent.right anchors.top: parent.top - anchors.topMargin: 0 - color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small - text: { - if (root.isActive) { - if (root.lastSegment.type === LlmSegment.Type.ToolCall) - return qsTr("Using %1...").arg(root.lastSegment.name); - return qsTr("Thinking..."); + anchors.left: parent.left + anchors.right: parent.right + + Item { + implicitHeight: expandBtn.implicitHeight + implicitWidth: expandBtn.implicitWidth + + LoadingIndicator { + id: spinnerReasoning + + implicitSize: collapsedText.implicitHeight + anchors.centerIn: parent + opacity: root.isActive ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + } + + IconButton { + id: expandBtn + + font.pointSize: Tokens.font.size.large + icon: "keyboard_arrow_down" + inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline + anchors.centerIn: parent + opacity: root.isActive ? 0 : 1 + rotation: root.expanded ? 180 : 0 + type: IconButton.Text + + Behavior on rotation { + Anim {} + } + + onClicked: root.expanded = !root.expanded } - return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1)); } - Behavior on opacity { - Anim {} + CustomText { + id: collapsedText + + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + Layout.fillWidth: true + Layout.leftMargin: Tokens.spacing.medium + text: { + if (root.isActive) { + if (root.lastSegment.type === LlmSegment.Type.ToolCall) + return qsTr("Using %1...").arg(root.lastSegment.name); + return qsTr("Thinking..."); + } + return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1)); + } + + Behavior on opacity { + Anim {} + } } } - CustomClippingRect { + CustomRect { id: expandedRect anchors.left: parent.left anchors.right: parent.right - anchors.top: collapsedText.bottom - anchors.topMargin: Tokens.spacing.medium + anchors.top: layout.bottom + anchors.bottom: parent.bottom + anchors.topMargin: Tokens.spacing.small + anchors.bottomMargin: Tokens.spacing.small color: Colors.palette.m3surfaceContainerLow - implicitHeight: root.expanded ? expandedContent.contentHeight + expandedContent.anchors.margins * 2 : 0 + implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2 opacity: root.expanded ? 1 : 0 radius: Tokens.rounding.medium visible: opacity > 0 - Behavior on implicitHeight { - Anim { - type: Anim.DefaultEffects - } - } Behavior on opacity { Anim {} } diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index 3d3e4a7..5d1cd78 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -1,10 +1,11 @@ qml_module(ZShell-components URI ZShell.Components - SOURCES - lazylistview.hpp lazylistview.cpp + SOURCES + lazylistview.hpp lazylistview.cpp wavyline.hpp wavyline.cpp buttonrow.hpp buttonrow.cpp carouselview.hpp carouselview.cpp - LIBRARIES - Qt::Quick + wheelinverter.hpp + LIBRARIES + Qt::Quick ) diff --git a/Plugins/ZShell/Components/wheelinverter.hpp b/Plugins/ZShell/Components/wheelinverter.hpp new file mode 100644 index 0000000..38aa599 --- /dev/null +++ b/Plugins/ZShell/Components/wheelinverter.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +class WheelInverter : public QQuickItem { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + QQuickItem* target READ target WRITE setTarget NOTIFY targetChanged) + + public: + using QQuickItem::QQuickItem; + + QQuickItem* target() const { return m_target; } + + void setTarget(QQuickItem* target) { + if (m_target == target) return; + + if (m_target) m_target->removeEventFilter(this); + + m_target = target; + + if (m_target) m_target->installEventFilter(this); + + emit targetChanged(); + } + + protected: + class InvertedWheelEvent : public QWheelEvent { + public: + using QWheelEvent::QWheelEvent; + + bool invertedByWheelInverter = true; + }; + + bool eventFilter(QObject* watched, QEvent* event) override { + if (watched != m_target || event->type() != QEvent::Wheel) + return QQuickItem::eventFilter(watched, event); + + auto* wheel = static_cast(event); + + const bool inverted = dynamic_cast(wheel) != + nullptr; + + if (inverted) return false; + + auto* window = m_target ? m_target->window() : nullptr; + if (!window) { + qInfo() << "[WheelInverter] no window"; + return false; + } + + const QPointF windowPos = + window->mapFromGlobal(wheel->globalPosition()); + + auto* invertedEvent = new InvertedWheelEvent( + windowPos, + wheel->globalPosition(), + -wheel->pixelDelta(), + -wheel->angleDelta(), + wheel->buttons(), + wheel->modifiers(), + wheel->phase(), + wheel->inverted(), + wheel->source(), + wheel->pointingDevice()); + + invertedEvent->setTimestamp(wheel->timestamp()); + + QCoreApplication::postEvent(window, invertedEvent); + + return true; + } + + signals: + void targetChanged(); + + private: + QPointer m_target; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index abe0c0e..5e97a78 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -23,6 +23,7 @@ class ChatSession : public QObject { Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT) Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged) + Q_PROPERTY(bool loaded READ isLoaded NOTIFY loaded) Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) Q_PROPERTY( ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT)