better chat list view + anchor to bottom. sqlite db for chats + LIFO-ordered

This commit is contained in:
2026-08-23 16:37:11 +02:00
parent 37c6a9986e
commit aae3757daf
36 changed files with 2653 additions and 1459 deletions
@@ -1,10 +1,8 @@
pragma ComponentBehavior: Bound
import ZShell.Config
import ZShell.Llm
import Quickshell
import QtQuick
import QtQuick.Layouts
import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Components
import qs.Services
@@ -13,18 +11,23 @@ Item {
property ChatSession chatData
property bool following: true
readonly property int messageCount: chatData.messages.length
signal requestClose
function send(): void {
if (Chat.busy || input.text.trim() === "")
function scrollToBottom(): void {
Qt.callLater(list.positionViewAtBeginning);
}
function send(text: string): void {
if (text.trim() === "")
return;
following = true;
Chat.send(chatData.id, input.text);
chatData.sendMessage(text);
input.text = "";
}
Component.onCompleted: input.focus = true
RowLayout {
id: header
@@ -54,194 +57,58 @@ Item {
VerticalFadeListView {
id: list
readonly property real bottomThreshold: Tokens.spacing.extraSmall
property bool stickToBottom: true
property bool userScrolledUp: false
function scrollToEnd(animated: bool): void {
const target = Math.max(0, list.contentHeight - list.height);
if (!animated) {
scrollAnim.stop();
list.contentY = target;
return;
}
scrollAnim.to = target;
scrollAnim.restart();
}
anchors.bottom: inputRow.top
anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.medium
anchors.left: parent.left
anchors.right: parent.right
anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.medium
cacheBuffer: height * 20
clip: true
currentIndex: messages.values.length - 1
fadeAmount: 0.05
fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel
spacing: Tokens.spacing.medium
verticalLayoutDirection: VerticalFadeListView.BottomToTop
CustomScrollBar.vertical: CustomScrollBar {
flickable: list
}
delegate: ColumnLayout {
id: messageRow
readonly property bool isUser: modelData.role === ChatMessage.Role.User
required property var modelData
anchors.left: parent.left
anchors.leftMargin: messageRow.isUser ? 0 : Tokens.spacing.extraSmall
anchors.right: parent.right
anchors.rightMargin: messageRow.isUser ? Tokens.spacing.extraSmall : 0
Loader {
Layout.fillWidth: true
Layout.rightMargin: Tokens.spacing.extraLarge
active: !messageRow.isUser && messageRow.modelData.reasoning !== ""
sourceComponent: CustomRect {
id: reasoning
property bool expanded: false
color: Colors.palette.m3surfaceContainer
implicitHeight: expanded ? expandedText.implicitHeight : collapsedText.implicitHeight + Tokens.padding.medium * 2
radius: Tokens.rounding.medium
Behavior on implicitHeight {
Anim {
}
}
Loader {
id: spinnerReasoning
active: opacity > 0
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.verticalCenter: parent.verticalCenter
opacity: messageRow.modelData.reasoningActive ? 1 : 0
Behavior on opacity {
Anim {
}
}
sourceComponent: LoadingIndicator {
implicitSize: collapsedText.implicitHeight
}
}
MaterialIcon {
id: reasoningDone
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.verticalCenter: parent.verticalCenter
font.pointSize: Tokens.font.size.large
opacity: messageRow.modelData.reasoningActive || reasoning.expanded ? 0 : 1
text: "check"
Behavior on opacity {
Anim {
}
}
}
CustomText {
id: collapsedText
anchors.left: messageRow.modelData.reasoningActive ? spinnerReasoning.right : reasoningDone.right
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
opacity: reasoning.expanded ? 0 : 1
text: messageRow.modelData.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((messageRow.modelData.reasoningElapsedMs / 1000).toFixed(1))
visible: opacity > 0
Behavior on opacity {
Anim {
}
}
}
CustomText {
id: expandedText
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
color: Colors.palette.m3onSurface
opacity: reasoning.expanded ? 1 : 0
text: messageRow.modelData.reasoning
visible: opacity > 0
wrapMode: Text.WordWrap
Behavior on opacity {
Anim {
}
}
}
StateLayer {
onClicked: reasoning.expanded = !reasoning.expanded
}
}
}
CustomRect {
Layout.alignment: messageRow.isUser ? Qt.AlignRight : Qt.AlignLeft
color: messageRow.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
implicitHeight: Math.max(msgText.implicitHeight + Tokens.padding.medium * 2, spinnerLoader.implicitHeight)
implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, messageRow.ListView.view.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge)
radius: Tokens.rounding.medium
visible: !messageRow.modelData.reasoningActive
CustomText {
id: msgText
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
text: messageRow.modelData.content
textFormat: CustomText.MarkdownText
wrapMode: Text.WordWrap
}
Loader {
id: spinnerLoader
active: messageRow.modelData.streaming && msgText.text.length === 0
anchors.left: parent.left
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
sourceComponent: Item {
implicitHeight: 18
implicitWidth: 18
LoadingIndicator {
anchors.fill: parent
}
}
}
add: Transition {
Anim {
from: list.width
property: "x"
to: 0
}
}
model: ScriptModel {
id: messages
values: root.chatData.messages
delegate: MessageDelegate {}
displaced: Transition {
Anim {
property: "y"
}
}
move: Transition {
Anim {
property: "y"
}
}
Component.onCompleted: scrollToEnd(false)
Component.onCompleted: positionViewAtBeginning()
onAtYEndChanged: {
if (atYEnd)
userScrolledUp = false;
}
onContentHeightChanged: {
if (list.stickToBottom)
list.scrollToEnd(false);
if (!userScrolledUp && atYEnd)
root.scrollToBottom();
}
onMovementEnded: {
list.stickToBottom = (list.contentY >= list.contentHeight - list.height - list.bottomThreshold);
onCountChanged: {
if (!userScrolledUp)
root.scrollToBottom();
}
onMovingChanged: {
if (moving)
userScrolledUp = !atYEnd;
}
onMovementStarted: list.stickToBottom = false
Anim {
id: scrollAnim
@@ -252,88 +119,53 @@ Item {
}
}
ColumnLayout {
EmptyBackground {
id: emptyState
anchors.fill: parent
spacing: Tokens.spacing.small
visible: (root.messageCount === 0 && !Chat.busy) || !root.chatData
Item {
Layout.fillHeight: true
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant
font.pointSize: 36
text: "chat"
}
CustomText {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant
text: !root.chatData ? qsTr("Open a previous chat or start a new one") : qsTr("No messages yet")
}
Item {
Layout.fillHeight: true
}
visible: !root.chatData
}
IconButton {
id: scrollToBottom
anchors.bottom: inputRow.top
anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.extraLarge
anchors.horizontalCenter: parent.horizontalCenter
font.pointSize: Tokens.font.size.large
icon: "arrow_downward"
isRound: true
padding: Tokens.padding.extraSmall
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 ? 1 : 0
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0
type: IconButton.Filled
visible: scale > 0
Behavior on scale {
Anim {
}
Anim {}
}
onClicked: {
list.stickToBottom = true;
list.scrollToEnd(true);
list.positionViewAtBeginning();
}
}
RowLayout {
id: inputRow
ChatInput {
id: input
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
spacing: Tokens.spacing.extraSmall
bg.border.color: Colors.palette.m3outlineVariant
bg.color: Colors.tPalette.m3surfaceContainerLowest
font.pointSize: Tokens.font.size.normal
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
placeholderText: qsTr("Send a message")
sendIcon.font.pointSize: Tokens.font.size.large
sendIcon.icon: "arrow_upward"
sendIcon.padding: Tokens.padding.extraSmall
CustomTextField {
id: input
Layout.fillWidth: true
placeholderText: qsTr("Send a message...")
type: CustomTextField.Filled
onAccepted: root.send()
}
IconButton {
id: actionBtn
enabled: Chat.busy || input.text.trim() !== ""
font.pointSize: Tokens.font.size.normal * 2
icon: Chat.busy ? "stop" : "send"
isRound: true
type: IconButton.Filled
onClicked: Chat.busy ? Chat.stop() : root.send()
}
onAccepted: root.send(text)
onSendPressed: root.send(text)
}
}