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
@@ -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
}
}
}