add removal of chat sessions, auto title + icon, proper stick-to-bottom as response is streamed

This commit is contained in:
2026-08-20 17:40:33 +02:00
parent 47bab21e22
commit 9657e5092a
12 changed files with 776 additions and 273 deletions
@@ -17,10 +17,6 @@ Item {
signal requestClose
function atBottom(): bool {
return list.contentHeight - list.height - list.contentY < 48;
}
function send(): void {
if (Chat.busy || input.text.trim() === "")
return;
@@ -29,30 +25,62 @@ Item {
input.text = "";
}
RowLayout {
id: header
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: Tokens.spacing.large
IconButton {
icon: "arrow_back"
inactiveColor: Colors.tPalette.m3surfaceContainerHigh
inactiveOnColor: Colors.palette.m3onSurfaceVariant
isRound: true
type: IconButton.Tonal
onClicked: root.requestClose()
}
CustomText {
Layout.fillWidth: true
elide: Text.ElideRight
font.pointSize: Tokens.font.size.larger
text: qsTr(root.chatData.title)
}
}
VerticalFadeListView {
id: list
readonly property real bottomThreshold: Tokens.spacing.extraSmall
property bool stickToBottom: true
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.bottomMargin: Tokens.spacing.medium
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.top: header.bottom
cacheBuffer: height * 20
clip: true
currentIndex: messages.values.length - 1
fadeAmount: 0.1
highlightFollowsCurrentItem: true
highlightRangeMode: ListView.ApplyRange
preferredHighlightEnd: list.height
spacing: Tokens.spacing.medium
CustomScrollBar.vertical: CustomScrollBar {
flickable: list
}
Behavior on contentY {
Anim {
}
}
delegate: ColumnLayout {
id: messageRow
@@ -176,6 +204,7 @@ Item {
anchors.top: parent.top
color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
text: messageRow.modelData.content
textFormat: CustomText.MarkdownText
wrapMode: Text.WordWrap
}
@@ -204,7 +233,23 @@ Item {
values: root.chatData.messages
}
Component.onCompleted: positionViewAtEnd()
Component.onCompleted: scrollToEnd(false)
onContentHeightChanged: {
if (list.stickToBottom)
list.scrollToEnd(false);
}
onMovementEnded: {
list.stickToBottom = (list.contentY >= list.contentHeight - list.height - list.bottomThreshold);
}
onMovementStarted: list.stickToBottom = false
Anim {
id: scrollAnim
property: "contentY"
target: list
type: Anim.DefaultEffects
}
}
ColumnLayout {
@@ -236,6 +281,31 @@ Item {
}
}
IconButton {
id: scrollToBottom
anchors.bottom: inputRow.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
type: IconButton.Filled
visible: scale > 0
Behavior on scale {
Anim {
}
}
onClicked: {
list.stickToBottom = true;
list.scrollToEnd(true);
}
}
RowLayout {
id: inputRow
@@ -258,23 +328,12 @@ Item {
id: actionBtn
enabled: Chat.busy || input.text.trim() !== ""
font.pointSize: 20
font.pointSize: Tokens.font.size.normal * 2
icon: Chat.busy ? "stop" : "send"
isRound: true
type: IconButton.Filled
onClicked: Chat.busy ? Chat.stop() : root.send()
}
}
IconButton {
anchors.left: parent.left
anchors.top: parent.top
icon: "arrow_back"
inactiveColor: Colors.tPalette.m3surfaceContainerHigh
inactiveOnColor: Colors.palette.m3onSurfaceVariant
isRound: true
type: IconButton.Tonal
onClicked: root.requestClose()
}
}
@@ -0,0 +1,88 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
CustomRect {
id: root
property alias layout: layout
required property ChatSession modelData
color: Colors.tPalette.m3surfaceContainer
implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin
MaterialIcon {
id: chatIcon
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.large
anchors.verticalCenter: parent.verticalCenter
font.pointSize: Tokens.font.size.extraLarge
text: root.modelData.icon || "check"
}
ColumnLayout {
id: layout
anchors.bottom: parent.bottom
anchors.bottomMargin: Tokens.padding.small
anchors.left: chatIcon.right
anchors.leftMargin: Tokens.padding.medium
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.medium
anchors.top: parent.top
anchors.topMargin: Tokens.padding.small
spacing: Tokens.spacing.extraSmall
CustomRect {
id: titleContainer
readonly property bool enoughSpace: width > title.implicitWidth + timeSep.implicitWidth + timeSep.anchors.leftMargin + timestamp.implicitWidth + timestamp.anchors.leftMargin
Layout.fillWidth: true
implicitHeight: enoughSpace ? title.implicitHeight : title.implicitHeight + timestamp.implicitHeight + timestamp.anchors.topMargin
CustomText {
id: title
text: root.modelData.title
}
CustomText {
id: timeSep
anchors.left: title.right
anchors.leftMargin: Tokens.spacing.small
anchors.verticalCenter: title.verticalCenter
color: Colors.palette.m3onSurfaceVariant
text: "•"
visible: titleContainer.enoughSpace
}
CustomText {
id: timestamp
anchors.left: titleContainer.enoughSpace ? timeSep.right : parent.left
anchors.leftMargin: titleContainer.enoughSpace ? Tokens.spacing.small : 0
anchors.top: titleContainer.enoughSpace ? undefined : title.bottom
anchors.topMargin: Tokens.spacing.extraSmall
anchors.verticalCenter: titleContainer.enoughSpace ? title.verticalCenter : undefined
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small
text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")
}
}
CustomText {
Layout.fillWidth: true
color: Colors.palette.m3outline
elide: Text.ElideRight
font.pointSize: Tokens.font.size.small
maximumLineCount: 1
text: root.modelData.messages[root.modelData.messages.length - 1]?.content.replace(/\s+/g, " ").trim() ?? ""
}
}
}
+138 -28
View File
@@ -13,55 +13,165 @@ Item {
property alias model: list.model
signal deleteChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession)
signal newChatRequest
CustomText {
id: header
anchors.left: parent.left
anchors.margins: Tokens.padding.extraSmall
anchors.right: parent.right
anchors.top: parent.top
color: Colors.palette.m3outline
font.family: "CaskaydiaCove NF"
font.pointSize: 13
font.weight: 500
text: qsTr("%1 Chat%2").arg(list.count).arg(list.count > 1 ? "s" : "")
}
CustomListView {
id: list
anchors.fill: parent
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.medium
cacheBuffer: height * 2
clip: true
spacing: Tokens.spacing.medium
delegate: CustomRect {
id: chatItem
delegate: Component {
Item {
id: chatItem
required property var modelData
property bool closing: false
property bool editVisible: false
required property var modelData
property real startY: 0.0
color: Colors.tPalette.m3surfaceContainer
implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin
implicitWidth: ListView.view.width
radius: Tokens.rounding.medium
anchors.fill: undefined
implicitHeight: chat.layout.implicitHeight + chat.layout.anchors.topMargin + chat.layout.anchors.bottomMargin
implicitWidth: ListView.view.width
ColumnLayout {
id: layout
onEditVisibleChanged: if (!editVisible)
chat.x = 0
anchors.bottomMargin: Tokens.padding.small
anchors.fill: parent
anchors.leftMargin: Tokens.padding.medium
anchors.rightMargin: Tokens.padding.medium
anchors.topMargin: Tokens.padding.small
spacing: 0
ParallelAnimation {
id: removeAnim
CustomText {
id: title
onFinished: root.deleteChatRequest(chatItem.modelData)
onStarted: chatItem.closing = true
text: chatItem.modelData.title
Anim {
property: "implicitHeight"
target: chatItem
to: 0
}
Anim {
property: "x"
target: chat
to: -chat.implicitWidth
}
Anim {
property: "opacity"
target: chatItem
to: 0
}
}
CustomText {
id: timestamp
ChatDelegate {
id: chat
color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.small
text: chatItem.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")
implicitWidth: chatItem.implicitWidth
modelData: chatItem.modelData
radius: Tokens.rounding.medium
Behavior on x {
Anim {
}
}
Behavior on y {
Anim {
}
}
StateLayer {
cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.PointingHandCursor
drag.axis: Drag.XAxis
drag.target: chat
hoverEnabled: true
preventStealing: true
onClicked: {
if (chatItem.editVisible)
chatItem.editVisible = false;
else
root.loadChatRequest(chatItem.modelData);
}
onPressed: event => {
chatItem.startY = event.y;
}
onReleased: event => {
if (chat.x > -(editTools.implicitWidth + Tokens.padding.extraSmall)) {
chat.x = 0;
} else {
chatItem.editVisible = true;
chat.x = -(editTools.implicitWidth + Tokens.spacing.medium + Tokens.padding.extraSmall);
}
}
}
}
}
StateLayer {
onClicked: {
root.loadChatRequest(chatItem.modelData);
CustomClippingRect {
anchors.left: chat.right
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.extraSmall
anchors.verticalCenter: chat.verticalCenter
implicitHeight: editTools.implicitHeight
RowLayout {
id: editTools
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
IconButton {
id: deleteChatBtn
font.pointSize: Tokens.font.size.large
icon: "close"
radius: Tokens.rounding.full
transform: [
Scale {
property bool shouldBeActive: (chat.x < -deleteChatBtn.implicitWidth) && !chatItem.closing
origin.x: deleteChatBtn.implicitWidth / 2
origin.y: deleteChatBtn.implicitWidth / 2
xScale: shouldBeActive ? 1 : 0
yScale: shouldBeActive ? 1 : 0
Behavior on xScale {
Anim {
}
}
Behavior on yScale {
Anim {
}
}
}
]
onClicked: {
removeAnim.start();
}
}
}
}
}
}
@@ -42,6 +42,9 @@ Item {
values: Chat.chats.values
}
onDeleteChatRequest: chat => {
Chat.chats.remove(chat);
}
onLoadChatRequest: chat => {
stack.push(chatContent, {
"chatData": chat