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)
}
}
@@ -1,91 +1,311 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
CustomRect {
CustomClippingRect {
id: root
required property Item chatItem
property alias layout: layout
property bool expanded: false
required property ChatSession modelData
signal clicked(content: ChatSession)
signal remove(content: ChatSession)
color: Colors.tPalette.m3surfaceContainer
implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin
implicitHeight: {
let h = 0;
h += infoContainer.implicitHeight;
if (expanded)
h += body.implicitHeight + body.topMargin + actionRow.implicitHeight + actionRow.anchors.topMargin;
else
h += preview.implicitHeight + preview.topMargin;
h += Tokens.padding.small;
const icon = chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2;
if (h < icon)
return icon;
return h;
}
radius: Tokens.rounding.large
Behavior on implicitHeight {
enabled: titleText.readOnly
Anim {}
}
MaterialIcon {
id: chatIcon
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.large
anchors.verticalCenter: parent.verticalCenter
color: root.chatItem.ListView.isCurrentItem ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
anchors.top: parent.top
anchors.topMargin: Tokens.padding.large
font.pointSize: Tokens.font.size.extraLarge
text: root.modelData.icon || "check"
}
ColumnLayout {
id: layout
Item {
id: infoContainer
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.leftMargin: Tokens.spacing.medium
anchors.top: parent.top
anchors.topMargin: Tokens.padding.small
spacing: Tokens.spacing.extraSmall
anchors.right: expandBtn.left
anchors.rightMargin: Tokens.spacing.medium
implicitHeight: {
let h = 0;
h += title.implicitHeight + title.anchors.topMargin;
h += timestamp.implicitHeight + timestamp.anchors.topMargin;
if (root.expanded)
h += created.implicitHeight + created.anchors.topMargin;
return h;
}
CustomRect {
id: titleContainer
id: title
readonly property bool enoughSpace: width > title.implicitWidth + timeSep.implicitWidth + timeSep.anchors.leftMargin + timestamp.implicitWidth + timestamp.anchors.leftMargin
anchors.left: parent.left
anchors.topMargin: Tokens.padding.extraSmall
anchors.top: parent.top
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 1)
implicitHeight: titleText.implicitHeight + Tokens.padding.extraSmall * 2
radius: Tokens.rounding.full
implicitWidth: titleText.implicitWidth + Tokens.padding.large * 2
opacity: titleText.readOnly ? 0 : 1
Layout.fillWidth: true
implicitHeight: enoughSpace ? title.implicitHeight : title.implicitHeight + timestamp.implicitHeight + timestamp.anchors.topMargin
Behavior on opacity {
Anim {}
}
}
CustomText {
id: title
TextEditBase {
id: titleText
color: root.chatItem.ListView.isCurrentItem ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
text: root.modelData.title
anchors.left: parent.left
anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
anchors.top: parent.top
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
font.pointSize: Tokens.font.size.normal
text: root.modelData.title
readOnly: true
Behavior on anchors.leftMargin {
Anim {}
}
Behavior on anchors.topMargin {
Anim {}
}
CustomText {
id: timeSep
anchors.left: title.right
anchors.leftMargin: Tokens.spacing.small
anchors.verticalCenter: title.verticalCenter
color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : 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: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small
text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")
onEditingFinished: root.modelData.title = text
onReadOnlyChanged: {
if (!readOnly) {
this.forceActiveFocus();
cursorPosition = text.length;
} else {
root.forceActiveFocus();
}
}
}
CustomText {
Layout.fillWidth: true
color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant
elide: Text.ElideRight
id: created
anchors.top: title.bottom
anchors.topMargin: Tokens.spacing.extraSmall
anchors.left: parent.left
anchors.right: parent.right
font.pointSize: Tokens.font.size.small
maximumLineCount: 1
text: root.modelData.messages[root.modelData.messages.length - 1]?.content.replace(/\s+/g, " ").trim() ?? ""
color: Colors.palette.m3onSurfaceVariant
text: qsTr("Created on: %1").arg(root.modelData.createdAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP"))
opacity: root.expanded ? 1 : 0
Behavior on opacity {
Anim {}
}
}
CustomText {
id: updated
anchors.top: created.bottom
anchors.topMargin: Tokens.spacing.small
anchors.left: parent.left
font.pointSize: Tokens.font.size.small
color: Colors.palette.m3onSurfaceVariant
text: qsTr("Updated on: ")
opacity: root.expanded ? 1 : 0
Behavior on opacity {
Anim {}
}
}
CustomText {
id: timestamp
anchors.left: parent.left
anchors.top: titleText.bottom
anchors.topMargin: Tokens.spacing.extraSmall
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")
states: State {
name: "expanded"
when: root.expanded
AnchorChanges {
target: timestamp
anchors.left: updated.right
anchors.top: undefined
anchors.verticalCenter: updated.verticalCenter
}
}
transitions: Transition {
AnchorAnim {}
}
}
}
CustomText {
id: preview
readonly property int topMargin: Tokens.spacing.extraSmall
anchors.left: chatIcon.right
anchors.margins: Tokens.spacing.medium
anchors.right: parent.right
y: infoContainer.implicitHeight + topMargin
elide: Text.ElideRight
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small
maximumLineCount: 1
opacity: !root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? ""
Behavior on y {
Anim {}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
CustomText {
id: body
readonly property int topMargin: root.expanded ? Tokens.spacing.medium : Tokens.spacing.extraSmall
anchors.left: chatIcon.right
anchors.margins: Tokens.spacing.medium
anchors.right: parent.right
y: infoContainer.implicitHeight + topMargin
animate: true
color: Colors.palette.m3onSurfaceVariant
maximumLineCount: 5
opacity: root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? ""
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on y {
Anim {}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
StateLayer {
id: sLayer
property int startY
onClicked: {
root.clicked(root.modelData);
}
}
ButtonRow {
id: actionRow
anchors.top: body.bottom
anchors.left: chatIcon.right
anchors.right: parent.right
anchors.topMargin: Tokens.spacing.small
anchors.leftMargin: Tokens.spacing.medium
anchors.rightMargin: Tokens.padding.medium
spacing: Tokens.spacing.medium
IconButton {
icon: "close"
fillWidth: true
shapeMorph: true
isRound: true
font.pointSize: Tokens.font.size.large
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
inactiveOnColor: Colors.palette.m3onSurfaceVariant
onClicked: root.remove(root.modelData)
}
IconButton {
icon: titleText.readOnly ? "edit" : "check"
label.animate: true
isToggle: true
fillWidth: true
shapeMorph: true
font.pointSize: Tokens.font.size.large
isRound: true
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
inactiveOnColor: Colors.palette.m3onSurfaceVariant
activeColor: Colors.palette.m3secondary
activeOnColor: Colors.palette.m3onSecondary
checked: !titleText.readOnly
onClicked: titleText.readOnly = !titleText.readOnly
}
}
CustomRect {
id: expandBtn
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Tokens.padding.small
implicitHeight: expandIcon.implicitHeight
implicitWidth: expandIcon.implicitHeight
radius: Tokens.rounding.full
color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 3)
StateLayer {
onClicked: root.expanded = !root.expanded
}
MaterialIcon {
id: expandIcon
anchors.centerIn: parent
anchors.verticalCenterOffset: root.expanded ? -1 : 1
rotation: root.expanded ? 180 : 0
text: "expand_more"
Behavior on anchors.verticalCenterOffset {
Anim {}
}
Behavior on rotation {
Anim {}
}
}
}
}
@@ -0,0 +1,87 @@
import QtQuick
import ZShell.Config
import qs.Components
import qs.Services
TextFieldBase {
id: root
readonly property alias bg: bg
readonly property alias sendIcon: sendIcon
signal sendPressed
bottomPadding: Tokens.padding.large
leftPadding: Tokens.padding.extraLarge
rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small
topPadding: Tokens.padding.large
wrapMode: TextFieldBase.Wrap
background: CustomRect {
id: bg
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainer
radius: Tokens.rounding.extraLarge
StateLayer {
id: stateLayer
cursorShape: Qt.IBeamCursor
enabled: !root.activeFocus
manualPressOverride: tapHandler.pressed
onClicked: root.focus = true
}
}
onPressed: {
if (stateLayer.enabled)
stateLayer.press(stateLayer.mouseX, stateLayer.mouseY);
}
CustomText {
id: placeholder
anchors.left: parent.left
anchors.leftMargin: root.leftPadding
anchors.verticalCenter: parent.verticalCenter
color: root.placeholderTextColor
font: root.font
opacity: root.text ? 0 : 1
text: root.placeholderText
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
IconButton {
id: sendIcon
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.small
anchors.verticalCenter: parent.verticalCenter
enabled: root.text
icon: "upward_arrow"
opacity: root.text ? 1 : 0
radius: Tokens.rounding.full
radiusMorph: false
stateLayer.hoverEnabled: enabled
type: IconButton.Text
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
onClicked: root.sendPressed()
}
TapHandler {
id: tapHandler
}
}
+6 -133
View File
@@ -15,7 +15,7 @@ Item {
property alias model: list.model
signal deleteChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession, index: int)
signal loadChatRequest(content: ChatSession)
signal newChatRequest
CustomText {
@@ -44,140 +44,13 @@ Item {
clip: true
spacing: Tokens.spacing.medium
delegate: Component {
Item {
id: chatItem
delegate: ChatDelegate {
id: chat
property alias chat: chat
property bool closing: false
property bool editVisible: false
required property int index
required property var modelData
property real startY: 0.0
implicitWidth: ListView.view.width
anchors.fill: undefined
implicitHeight: chat.layout.implicitHeight + chat.layout.anchors.topMargin + chat.layout.anchors.bottomMargin
implicitWidth: ListView.view.width
onEditVisibleChanged: if (!editVisible)
chat.x = 0
ParallelAnimation {
id: removeAnim
onFinished: root.deleteChatRequest(chatItem.modelData)
onStarted: chatItem.closing = true
Anim {
property: "implicitHeight"
target: chatItem
to: 0
}
Anim {
property: "x"
target: chat
to: -chat.implicitWidth
}
Anim {
property: "opacity"
target: chatItem
to: 0
}
}
ChatDelegate {
id: chat
chatItem: chatItem
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, chatItem.index);
}
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);
}
}
}
}
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();
}
}
}
}
}
onClicked: content => root.loadChatRequest(content)
onRemove: content => root.deleteChatRequest(content)
}
}
@@ -43,20 +43,19 @@ Item {
active: ChatState.isWindow && root.width > (ChatState.screen.width / 4)
anchors.fill: parent
sourceComponent: SidebarView {
}
sourceComponent: SidebarView {}
}
IconButton {
anchors.right: parent.right
icon: "close"
visible: !ChatState.isWindow
onClicked: {
Detach.create();
Visibilities.getForActive().sidebar = false;
}
}
// IconButton {
// anchors.right: parent.right
// icon: "close"
// visible: !ChatState.isWindow
//
// onClicked: {
// Detach.create();
// Visibilities.getForActive().sidebar = false;
// }
// }
Component {
id: chatList
@@ -0,0 +1,114 @@
import QtQuick
import Quickshell
import ZShell.Config
import ZShell.Components
import ZShell.Llm
import qs.Components
import qs.Services
Item {
id: root
required property ChatGeneration current
required property TextEdit edit
required property bool hovered
readonly property bool isUser: message.role === ChatMessage.Role.User
required property ChatMessage message
implicitHeight: root.current.content !== "" ? editButton.implicitHeight : 0
implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small
ButtonRow {
id: generationTools
enabled: visible
visible: !root.isUser
IconButton {
enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== 0
icon: "chevron_left"
type: IconButton.Tonal
onClicked: {
if (!root.edit.readOnly)
root.edit.readOnly = true;
root.message.setActiveGeneration(root.message.activeGenerationIndex - 1);
}
}
IconButton {
enabled: false
icon: `${root.message.activeGenerationIndex + 1}`
label.font.family: Config.appearance.font.family.sans
label.font.pointSize: Tokens.font.size.small
type: IconButton.Text
}
IconButton {
enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== root.message.generationCount - 1
icon: "chevron_right"
type: IconButton.Tonal
onClicked: {
if (!root.edit.readOnly)
root.edit.readOnly = true;
root.message.setActiveGeneration(root.message.activeGenerationIndex + 1);
}
}
}
ButtonRow {
id: editTools
anchors.right: parent.right
opacity: !root.current.streaming && root.hovered ? 1 : 0
spacing: Tokens.spacing.small
visible: opacity > 0
Behavior on opacity {
Anim {
}
}
IconButton {
icon: "refresh"
inactiveColor: Colors.palette.m3secondary
inactiveOnColor: Colors.palette.m3onSecondary
scale: root.edit.readOnly ? 1 : 0
visible: !root.isUser
Behavior on scale {
Anim {
}
}
onClicked: {
root.message.retry();
}
}
IconButton {
icon: "content_copy"
inactiveColor: Colors.palette.m3secondary
inactiveOnColor: Colors.palette.m3onSecondary
scale: root.edit.readOnly ? 1 : 0
Behavior on scale {
Anim {
}
}
onClicked: Quickshell.clipboardText = root.current.content
}
IconButton {
id: editButton
icon: root.edit.readOnly ? "edit" : "check"
inactiveColor: Colors.palette.m3tertiary
inactiveOnColor: Colors.palette.m3onTertiary
onClicked: root.edit.readOnly = !root.edit.readOnly
}
}
}
@@ -0,0 +1,32 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import qs.Components
import qs.Services
ColumnLayout {
id: root
spacing: Tokens.spacing.small
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: qsTr("No messages yet")
}
Item {
Layout.fillHeight: true
}
}
@@ -0,0 +1,164 @@
pragma ComponentBehavior: Bound
import QtQuick
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
MouseArea {
id: root
readonly property ChatGeneration current: modelData.activeGeneration
required property int index
readonly property bool isUser: modelData.role === ChatMessage.Role.User
required property ChatMessage modelData
property bool reasoningExpanded: false
property real savedContentY: -1
function handleReasoningToggle(expanded: bool): void {
const view = root.ListView.view;
if (!view)
return;
if (expanded) {
root.savedContentY = view.contentY;
root.reasoningExpanded = true;
} else {
root.reasoningExpanded = false;
if (root.savedContentY !== -1) {
restoreAnim.to = root.savedContentY;
restoreAnim.restart();
root.savedContentY = -1;
}
}
}
anchors.left: parent.left
anchors.leftMargin: root.isUser ? 0 : Tokens.spacing.extraSmall
anchors.right: parent.right
anchors.rightMargin: root.isUser ? Tokens.spacing.extraSmall : 0
hoverEnabled: true
// Explicit, synchronous height — sum of children, no Layout polish involved.
implicitHeight: reasoningLoader.implicitHeight + (reasoningLoader.active ? Tokens.spacing.medium : 0) + bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
preventStealing: true
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
property: "contentY"
target: root.ListView.view
type: Anim.DefaultEffects
}
Loader {
id: reasoningLoader
readonly property bool shouldBeActive: root.current.reasoningActive || root.current.content === ""
active: !root.isUser
anchors.left: parent.left
anchors.right: parent.right
anchors.rightMargin: Tokens.spacing.extraLarge
anchors.top: parent.top
sourceComponent: Reasoning {
current: root.current
onExpandedChanged: {
root.handleReasoningToggle(expanded);
}
}
}
CustomRect {
id: bubble
anchors.left: root.isUser ? undefined : parent.left
anchors.right: root.isUser ? parent.right : undefined
anchors.top: reasoningLoader.active ? reasoningLoader.bottom : parent.top
anchors.topMargin: reasoningLoader.active ? Tokens.spacing.medium : 0
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
implicitHeight: root.current.content !== "" ? msgText.contentHeight + Tokens.padding.medium * 2 : 0
implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge)
radius: Tokens.rounding.medium
visible: !root.current.reasoningActive
TextEditBase {
id: msgText
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
animateCursor: false
color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary
font.pointSize: Tokens.font.size.smaller
readOnly: true
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
text: root.current.content
textFormat: CustomText.MarkdownText
wrapMode: Text.WordWrap
Keys.onPressed: event => {
if (event.key == Qt.Key_Return) {
if (!(event.modifiers & Qt.ShiftModifier)) {
readOnly = true;
event.accepted = true;
}
} else if (event.key == Qt.Key_Escape) {
text = root.current.content;
readOnly = true;
event.accepted = true;
}
}
onEditingFinished: {
const old = root.current.content;
if (old !== text) {
root.modelData.edit(text);
if (root.isUser)
root.modelData.generate();
}
readOnly = true;
}
onReadOnlyChanged: {
if (readOnly) {
animateCursor = false;
root.forceActiveFocus();
textFormat = CustomText.MarkdownText;
} else {
var raw = root.current.content;
textFormat = CustomText.PlainText;
text = raw;
forceActiveFocus();
cursorPosition = text.length;
animateCursor = true;
}
}
}
}
Actions {
id: actionsRow
anchors.left: parent.left
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
anchors.top: bubble.bottom
anchors.topMargin: Tokens.spacing.medium
current: root.current
edit: msgText
hovered: root.containsMouse
message: root.modelData
}
}
@@ -0,0 +1,107 @@
import QtQuick
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
Item {
id: root
required property ChatGeneration current
property bool expanded: false
readonly property bool isReasoning: root.current.reasoningActive || root.current.content === ""
implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin
LoadingIndicator {
id: spinnerReasoning
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.verticalCenter: parent.verticalCenter
implicitSize: collapsedText.implicitHeight
opacity: root.isReasoning ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {
}
}
}
IconButton {
id: expandBtn
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.verticalCenter: collapsedText.verticalCenter
font.pointSize: Tokens.font.size.large
icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
opacity: root.isReasoning ? 0 : 1
rotation: root.expanded ? 180 : 0
type: IconButton.Text
Behavior on rotation {
Anim {
}
}
onClicked: root.expanded = !root.expanded
}
CustomText {
id: collapsedText
anchors.left: root.current.reasoningActive ? spinnerReasoning.right : 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: root.current.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((root.current.reasoningElapsedMs / 1000).toFixed(1))
Behavior on opacity {
Anim {
}
}
}
CustomClippingRect {
id: expandedRect
anchors.left: parent.left
anchors.right: parent.right
anchors.top: collapsedText.bottom
anchors.topMargin: Tokens.spacing.medium
color: Colors.palette.m3surfaceContainerLow
implicitHeight: root.expanded ? expandedText.implicitHeight + expandedText.anchors.margins * 2 : -anchors.topMargin
implicitWidth: expandedText.implicitWidth + expandedText.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 {
}
}
CustomText {
id: expandedText
anchors.fill: parent
anchors.margins: Tokens.padding.medium
color: Colors.palette.m3onSurface
font.pointSize: Tokens.font.size.small
text: root.current.reasoning
textFormat: CustomText.MarkdownText
wrapMode: Text.WordWrap
}
}
}
@@ -8,26 +8,49 @@ import ZShell.Llm
import qs.Components
import qs.Services
RowLayout {
Item {
id: root
ChatList {
id: chatList
property int breakpoint: 700
property alias conversationModel: sidebar.model
property var currentConversation: null // whatever model item is "open"
Layout.fillHeight: true
Layout.fillWidth: true
Layout.maximumWidth: Config.sidebar.sizes.width
Layout.minimumWidth: Config.sidebar.sizes.width / 2
readonly property bool isWide: root.width >= root.breakpoint
property bool narrowShowsSidebar: true
function closeConversation() {
narrowShowsSidebar = true;
}
function openConversation(conv) {
currentConversation = conv;
narrowShowsSidebar = false; // in narrow mode, jump to the chat
}
ChatList {
id: sidebar
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: root.isWide ? undefined : parent.right
anchors.top: parent.top
implicitWidth: 20
list.currentIndex: -1
list.highlightFollowsCurrentItem: false
Behavior on anchors.right {
AnchorAnim {}
}
Behavior on implicitWidth {
Anim {}
}
list.highlight: CustomRect {
color: Colors.palette.m3primary
implicitHeight: chatList.list.currentItem?.implicitHeight ?? 0
implicitWidth: chatList.list.width
implicitHeight: sidebar.list.currentItem?.implicitHeight ?? 0
implicitWidth: sidebar.list.width
radius: Tokens.rounding.medium
x: chatList.list.currentItem?.chat.x ?? 0
y: chatList.list.currentItem?.y ?? 0
x: sidebar.list.currentItem?.chat.x ?? 0
y: sidebar.list.currentItem?.y ?? 0
Behavior on y {
Anim {
@@ -40,18 +63,29 @@ RowLayout {
values: Chat.chats.values
}
anchors.onRightChanged: {
if (anchors.right === undefined)
implicitWidth = 20;
}
onLoadChatRequest: (chat, index) => {
chatList.list.currentIndex = index;
chatContent.chatData = chat;
sidebar.list.currentIndex = index;
root.openConversation(chat);
}
}
ChatContent {
id: chatContent
CustomClippingWrapperRect {
anchors.bottom: parent.bottom
anchors.left: sidebar.right
anchors.right: parent.right
anchors.top: parent.top
child: conversationView
}
Layout.fillHeight: true
Layout.fillWidth: true
Layout.minimumWidth: Config.sidebar.sizes.width
Layout.preferredWidth: Math.round(Config.sidebar.sizes.width * 1.5)
ChatContent {
id: conversationView
anchors.fill: root
anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0
chatData: root.currentConversation
}
}