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 list: list property alias model: list.model property bool highlight: false property bool slim: false signal requestExpand signal deleteChatRequest(content: ChatSession) signal loadChatRequest(content: ChatSession, index: int) 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.bottom: modelsContainer.top anchors.left: parent.left anchors.right: parent.right anchors.top: header.bottom anchors.topMargin: Tokens.spacing.medium clip: true spacing: Tokens.spacing.medium add: Transition { Anim { from: list.width property: "x" } } remove: Transition { Anim { to: list.width property: "x" } Anim { to: 0 property: "opacity" } } displaced: Transition { Anim { property: "y" } } move: Transition { Anim { property: "y" } } delegate: ChatDelegate { id: chat required property int index implicitWidth: ListView.view.width highlighted: root.highlight && ChatState.chatSession === modelData slim: root.slim onHighlightedChanged: if (highlighted) list.currentIndex = index onClicked: content => root.loadChatRequest(content, index) onRemove: content => root.deleteChatRequest(content) } TapHandler { onTapped: { root.focus = true; ChatState.fabExpanded = false; modelsContainer.expanded = false; } } } CustomClippingRect { id: modelsContainer property bool expanded: false anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: fabRoot.left anchors.margins: Tokens.padding.small anchors.rightMargin: Tokens.spacing.small implicitHeight: expanded ? 400 : fabRoot.implicitHeight radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2 function prettyModelName(): string { const pathList = Chat.model.split("\/"); const name = pathList[pathList.length - 1]; return name; } Behavior on implicitHeight { Anim {} } Behavior on radius { Anim {} } Item { id: modelName anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top implicitHeight: fabRoot.implicitHeight CustomText { text: modelsContainer.prettyModelName() color: Colors.palette.m3onSurfaceVariant anchors.left: parent.left anchors.leftMargin: Tokens.padding.large anchors.verticalCenter: parent.verticalCenter } MaterialIcon { color: Colors.palette.m3onSurfaceVariant anchors.right: parent.right anchors.rightMargin: Tokens.padding.large anchors.verticalCenter: parent.verticalCenter text: modelsContainer.expanded ? "unfold_less" : "unfold_more" animate: true font.pointSize: Tokens.font.size.large } StateLayer { id: modelLayer onClicked: { ChatState.fabExpanded = false; modelsContainer.expanded = !modelsContainer.expanded; } radius: modelsContainer.radius } } VerticalFadeListView { id: modelsList anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom anchors.top: modelName.bottom anchors.margins: Tokens.padding.medium anchors.topMargin: Tokens.spacing.medium model: Chat.availableModels opacity: modelsContainer.expanded ? 1 : 0 currentIndex: Chat.availableModels.indexOf(Chat.model) visible: opacity > 0 Behavior on opacity { Anim {} } CustomScrollBar.vertical: CustomScrollBar { flickable: modelsList } delegate: CustomRect { id: model required property var modelData required property int index readonly property bool selected: model.ListView.view.currentIndex === model.index implicitWidth: ListView.view.width implicitHeight: layout.implicitHeight + Tokens.padding.small * 2 radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0) Behavior on radius { Anim { type: Anim.SlowEffects } } RowLayout { id: layout anchors.fill: parent anchors.margins: Tokens.padding.small anchors.leftMargin: Tokens.padding.large anchors.rightMargin: Tokens.padding.large CustomText { text: model.modelData color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface } MaterialIcon { Layout.alignment: Qt.AlignRight visible: model.selected color: Colors.palette.m3onTertiaryContainer font.pointSize: Tokens.font.size.normal opacity: model.selected ? 1 : 0 text: "check" Behavior on opacity { Anim { type: Anim.SlowEffects } } } } StateLayer { id: stateLayer onClicked: { Chat.selectModel(model.modelData); } } } } } IconButton { id: fabRoot anchors.bottom: parent.bottom anchors.right: parent.right anchors.margins: Tokens.padding.small padding: 8 font.pointSize: Math.round(18 * 1.2) icon: root.slim ? "left_panel_open" : "add" isRound: ChatState.fabExpanded // opacity: root.slim ? 0 : 1 // visible: opacity > 0 // // Behavior on opacity { // Anim { // type: Anim.DefaultEffects // } // } label.transform: Rotation { origin.y: fabRoot.label.height / 2 origin.x: fabRoot.label.width / 2 angle: ChatState.fabExpanded ? 135 : 0 Behavior on angle { Anim {} } } onClicked: { if (root.slim && !ChatState.narrowSidebarExpanded) { root.requestExpand(); return; } modelsContainer.expanded = false; ChatState.fabExpanded = !ChatState.fabExpanded; } Elevation { anchors.fill: parent level: fabRoot.stateLayer.containsMouse ? 4 : 3 radius: parent.radius z: -1 } } ColumnLayout { id: fabMenu anchors.bottom: fabRoot.top anchors.right: fabRoot.right anchors.bottomMargin: Tokens.padding.medium Repeater { id: fabRep model: ListModel { ListElement { name: "new chat" icon: "add" } ListElement { name: "pop out in window" icon: "open_in_new" } } CustomClippingRect { id: fabMenuItem required property var modelData required property int index Layout.alignment: Qt.AlignRight implicitHeight: fabMenuItemInner.implicitHeight + Tokens.padding.medium * 2 radius: sLayer.pressed ? Tokens.rounding.small : implicitHeight / 2 color: Colors.palette.m3primaryContainer visible: !(modelData.name === "pop out in window" && ChatState.isWindow) Behavior on radius { Anim { type: Anim.DefaultEffects } } opacity: 0 states: State { name: "visible" when: ChatState.fabExpanded PropertyChanges { fabMenuItem.implicitWidth: fabMenuItemInner.implicitWidth + Tokens.padding.large * 2 fabMenuItem.opacity: 1 fabMenuItemInner.opacity: 1 } } transitions: [ Transition { to: "visible" SequentialAnimation { PauseAnimation { duration: (fabRep.count - 1 - fabMenuItem.index) * 50 } ParallelAnimation { Anim { property: "implicitWidth" type: Anim.FastSpatial } Anim { property: "opacity" duration: Tokens.anim.durations.small easing: Tokens.anim.standard } } } }, Transition { from: "visible" SequentialAnimation { PauseAnimation { duration: fabMenuItem.index * Tokens.anim.durations.small / 8 } ParallelAnimation { Anim { property: "implicitWidth" type: Anim.FastSpatial } Anim { property: "opacity" duration: Tokens.anim.durations.small easing: Tokens.anim.standard } } } } ] RowLayout { id: fabMenuItemInner anchors.centerIn: parent spacing: Tokens.spacing.medium opacity: 0 MaterialIcon { text: fabMenuItem.modelData.icon color: Colors.palette.m3onPrimaryContainer font.pointSize: Tokens.font.size.large fill: 1 } CustomText { animate: true text: fabMenuItem.modelData.name font.capitalization: Font.Capitalize color: Colors.palette.m3onPrimaryContainer Layout.preferredWidth: implicitWidth Behavior on Layout.preferredWidth { Anim {} } } } StateLayer { id: sLayer onClicked: { const name = fabMenuItem.modelData.name; if (name === "new chat") root.newChatRequest(); else if (name === "pop out in window") Detach.create(); ChatState.fabExpanded = false; } } } } } }