initial commit for llm chat tab in sidebar

This commit is contained in:
2026-08-20 00:27:48 +02:00
parent d3e61efd86
commit 47bab21e22
22 changed files with 2085 additions and 9 deletions
@@ -0,0 +1,280 @@
pragma ComponentBehavior: Bound
import ZShell.Config
import ZShell.Llm
import Quickshell
import QtQuick
import QtQuick.Layouts
import qs.Components
import qs.Services
Item {
id: root
required property ChatSession chatData
property bool following: true
readonly property int messageCount: chatData.messages.length
signal requestClose
function atBottom(): bool {
return list.contentHeight - list.height - list.contentY < 48;
}
function send(): void {
if (Chat.busy || input.text.trim() === "")
return;
following = true;
Chat.send(chatData.id, input.text);
input.text = "";
}
VerticalFadeListView {
id: list
anchors.bottom: inputRow.top
anchors.bottomMargin: Tokens.spacing.medium
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
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
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
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
}
}
}
}
}
model: ScriptModel {
id: messages
values: root.chatData.messages
}
Component.onCompleted: positionViewAtEnd()
}
ColumnLayout {
id: emptyState
anchors.fill: parent
spacing: Tokens.spacing.small
visible: root.messageCount === 0 && !Chat.busy
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
}
}
RowLayout {
id: inputRow
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
spacing: Tokens.spacing.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: 20
icon: Chat.busy ? "stop" : "send"
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,92 @@
pragma ComponentBehavior: Bound
import Quickshell
import QtQuick
import QtQuick.Layouts
import ZShell.Llm
import ZShell.Config
import qs.Services
import qs.Components
Item {
id: root
property alias model: list.model
signal loadChatRequest(content: ChatSession)
signal newChatRequest
CustomListView {
id: list
anchors.fill: parent
cacheBuffer: height * 2
clip: true
spacing: Tokens.spacing.medium
delegate: CustomRect {
id: chatItem
required property var modelData
color: Colors.tPalette.m3surfaceContainer
implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin
implicitWidth: ListView.view.width
radius: Tokens.rounding.medium
ColumnLayout {
id: layout
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
CustomText {
id: title
text: chatItem.modelData.title
}
CustomText {
id: timestamp
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")
}
}
StateLayer {
onClicked: {
root.loadChatRequest(chatItem.modelData);
}
}
}
}
IconButton {
id: newChatBtn
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.small
anchors.right: parent.right
font.pointSize: Math.round(18 * 1.2)
icon: "add"
padding: 8
radius: Tokens.rounding.full
onClicked: {
root.newChatRequest();
}
Elevation {
anchors.fill: parent
level: newChatBtn.stateLayer.containsMouse ? 4 : 3
radius: parent.radius
z: -1
}
}
}
@@ -0,0 +1,73 @@
pragma ComponentBehavior: Bound
import ZShell.Config
import ZShell.Llm
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Modules.Notifications.Sidebar
import qs.Components
import qs.Services
Item {
id: root
property bool chatOpen: false
required property Props props
anchors.fill: parent
anchors.margins: Tokens.padding.small
Component.onCompleted: {
if (root.props.inChat) {
stack.push(chatList);
stack.push(chatContent, {
"chatData": root.props.chatSession
});
} else
stack.push(chatList);
}
StackView {
id: stack
anchors.fill: parent
}
Component {
id: chatList
ChatList {
model: ScriptModel {
values: Chat.chats.values
}
onLoadChatRequest: chat => {
stack.push(chatContent, {
"chatData": chat
});
root.props.inChat = true;
root.props.chatSession = chat;
}
onNewChatRequest: {
const data = Chat.chats.insert();
stack.push(chatContent, {
"chatData": data
});
root.props.inChat = true;
root.props.chatSession = data;
}
}
}
Component {
id: chatContent
ChatContent {
onRequestClose: {
stack.pop();
root.props.inChat = false;
}
}
}
}
+59 -6
View File
@@ -1,7 +1,11 @@
import qs.Components
pragma ComponentBehavior: Bound
import ZShell.Config
import ZShell.Llm
import QtQuick
import QtQuick.Layouts
import qs.Components
import qs.Modules.Notifications.Sidebar.Chat
import qs.Services
Item {
@@ -16,15 +20,64 @@ Item {
anchors.fill: parent
spacing: Tokens.spacing.small
CustomRect {
Tabs {
Layout.fillWidth: true
dashState: root.props
nonAnimWidth: layout.width
}
CustomClippingRect {
Layout.fillHeight: true
Layout.fillWidth: true
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
NotifDock {
props: root.props
visibilities: root.visibilities
Item {
id: pages
anchors.fill: parent
opacity: root.props.currentTab === 0 ? 1 : 0
visible: opacity > 0.01
Behavior on opacity {
Anim {
}
}
CustomRect {
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
NotifDock {
props: root.props
visibilities: root.visibilities
}
}
}
Item {
id: chatPage
anchors.fill: parent
opacity: visible ? 1 : 0
visible: root.props.currentTab === 1
z: 1
Behavior on opacity {
Anim {
}
}
CustomRect {
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
ChatPanel {
anchors.fill: parent
props: root.props
}
}
}
}
+4
View File
@@ -1,7 +1,11 @@
import Quickshell
import ZShell.Llm
PersistentProperties {
property ChatSession chatSession
property int currentTab: 0
property list<string> expandedNotifs: []
property bool inChat: false
reloadableId: "sidebar"
}
+142
View File
@@ -0,0 +1,142 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import QtQuick.Templates
import Quickshell
import ZShell.Config
import qs.Components
import qs.Services
Item {
id: root
readonly property alias count: bar.count
required property PersistentProperties dashState
required property real nonAnimWidth
implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight
TabBar {
id: bar
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
background: null
currentIndex: root.dashState.currentTab
implicitHeight: contentHeight
contentItem: RowLayout {
spacing: 0
Repeater {
model: bar.contentModel
}
}
onCurrentIndexChanged: root.state.currentTab = currentIndex
Tab {
iconName: "notifications"
text: qsTr("Notifications")
}
Tab {
iconName: "chat"
text: qsTr("Chat")
}
}
Item {
id: indicator
anchors.top: bar.bottom
clip: true
implicitHeight: 3
implicitWidth: bar.currentItem.implicitWidth
x: {
const tab = bar.currentItem;
const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count;
return width * tab.TabBar.index + (width - tab.implicitWidth) / 2;
}
Behavior on implicitWidth {
Anim {
}
}
Behavior on x {
Anim {
}
}
CustomRect {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
color: Colors.palette.m3primary
implicitHeight: parent.implicitHeight * 2
radius: Tokens.rounding.full
}
}
CustomRect {
id: separator
anchors.left: parent.left
anchors.right: parent.right
anchors.top: indicator.bottom
color: Colors.palette.m3outlineVariant
implicitHeight: 1
}
component Tab: TabButton {
id: tab
readonly property bool current: TabBar.tabBar.currentItem === this
required property string iconName
Layout.fillWidth: true
Layout.preferredWidth: 1
background: null
implicitHeight: implicitContentHeight
implicitWidth: implicitContentWidth
contentItem: Item {
implicitHeight: icon.height + label.height
implicitWidth: Math.max(icon.width, label.width)
StateLayer {
color: tab.current ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
radius: Tokens.rounding.small
onClicked: root.dashState.currentTab = tab.TabBar.index
}
MaterialIcon {
id: icon
anchors.bottom: label.top
anchors.horizontalCenter: parent.horizontalCenter
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant
fill: tab.current ? 1 : 0
font.pointSize: 18
text: tab.iconName
Behavior on fill {
Anim {
}
}
}
CustomText {
id: label
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant
text: tab.text
}
}
}
}
@@ -23,9 +23,12 @@ Item {
required property var visibilities
anchors.bottomMargin: (-implicitHeight - 5) * offsetScale
implicitHeight: content.implicitHeight + 8 * 2
clip: chatActive
implicitHeight: chatActive ? 21 : content.implicitHeight + 8 * 2
implicitWidth: sidebar.width * (1 - sidebar.offsetScale)
opacity: 1 - offsetScale
opacity: (1 - offsetScale) * chatFade
readonly property bool chatActive: sidebar.chatActive
property real chatFade: chatActive ? 0 : 1
visible: offsetScale < 1
Behavior on offsetScale {
@@ -35,6 +38,20 @@ Item {
}
}
Behavior on chatFade {
Anim {
duration: Tokens.anim.durations.expressiveDefaultSpatial
easing: Tokens.anim.expressiveDefaultSpatial
}
}
Behavior on implicitHeight {
Anim {
duration: Tokens.anim.durations.expressiveDefaultSpatial
easing: Tokens.anim.expressiveDefaultSpatial
}
}
Loader {
id: content
+16
View File
@@ -1,7 +1,11 @@
pragma ComponentBehavior: Bound
import qs.Components
import qs.Components.Toast
import ZShell
import ZShell.Config
import ZShell.Llm
import Quickshell
import QtQuick
Item {
@@ -12,6 +16,7 @@ Item {
readonly property Props props: Props {
}
readonly property bool shouldBeActive: root.visibilities.sidebar && Config.sidebar.enabled
readonly property bool chatActive: props.currentTab === 1
required property var visibilities
anchors.rightMargin: (-implicitWidth - 5) * offsetScale
@@ -26,6 +31,17 @@ Item {
}
}
Connections {
target: Chat
function onErrorOccurred(message: string): void {
if (root.shouldBeActive && root.chatActive)
return;
Toaster.toast(qsTr("Chat"), message, "error_outline", Toast.Error);
}
}
Loader {
id: content