import QtQuick import QtQuick.Layouts import ZShell.Components import ZShell.Config import ZShell.Llm import qs.Modules.Notifications.Sidebar.Chat.Content import qs.Components import qs.Services Item { id: root property ChatSession chatData property bool following: true // qmlformat off readonly property var keys: [ Qt.Key_A, Qt.Key_B, Qt.Key_C, Qt.Key_D, Qt.Key_E, Qt.Key_F, Qt.Key_G, Qt.Key_H, Qt.Key_I, Qt.Key_J, Qt.Key_K, Qt.Key_L, Qt.Key_M, Qt.Key_N, Qt.Key_O, Qt.Key_P, Qt.Key_Q, Qt.Key_R, Qt.Key_S, Qt.Key_T, Qt.Key_U, Qt.Key_V, Qt.Key_W, Qt.Key_X, Qt.Key_Y, Qt.Key_Z, Qt.Key_Agrave, Qt.Key_Aacute, Qt.Key_Acircumflex, Qt.Key_Atilde, Qt.Key_Adiaeresis, Qt.Key_Aring, Qt.Key_AE, Qt.Key_Ccedilla, Qt.Key_Egrave, Qt.Key_Eacute, Qt.Key_Ecircumflex, Qt.Key_Ediaeresis, Qt.Key_Igrave, Qt.Key_Iacute, Qt.Key_Icircumflex, Qt.Key_Idiaeresis, Qt.Key_ETH, Qt.Key_Ntilde, Qt.Key_Ograve, Qt.Key_Oacute, Qt.Key_Ocircumflex, Qt.Key_Otilde, Qt.Key_Odiaeresis, Qt.Key_Ooblique, Qt.Key_Ugrave, Qt.Key_Uacute, Qt.Key_Ucircumflex, Qt.Key_Udiaeresis, Qt.Key_Yacute, Qt.Key_ydiaeresis, Qt.Key_THORN, Qt.Key_ssharp ] // qmlformat on signal requestClose function send(text: string): void { if (text.trim() === "") return; following = true; chatData.sendMessage(text); input.text = ""; } function focusInput(): void { Qt.callLater(() => input.forceActiveFocus()); } onChatDataChanged: { if (chatData) focusInput(); } Keys.onPressed: e => { if (root.keys.includes(e.key)) { input.insert(input.length, e.text); focusInput(); } } 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 enabled: !ChatState.isWindow visible: enabled onClicked: root.requestClose() } CustomText { Layout.fillWidth: ChatState.isWindow ? false : true Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter elide: Text.ElideRight font.pointSize: Tokens.font.size.larger text: qsTr(root.chatData.title) } } Loader { id: listLoader 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 active: root.chatData && root.chatData.loaded sourceComponent: VerticalFadeListView { id: list property bool userScrolledUp: false property real lastCHeight: 0.0 function scrollToBottom(): void { scrollAnim.start(); } cacheBuffer: Math.max(height * 20, 0) clip: true fadeAmount: 0.05 fadeThreshold: Tokens.padding.medium model: root.chatData.messagesModel spacing: 0 rotation: 180 add: Transition { Anim { from: -100 property: "y" } } CustomScrollBar.vertical: CustomScrollBar { id: scrollBar flickable: list parent: list.parent anchors.top: list.top anchors.right: list.right anchors.bottom: list.bottom transform: Rotation { origin.y: list.height / 2 origin.x: scrollBar.width / 2 angle: 180 axis { y: 0 x: 1 z: 0 } } } delegate: MessageDelegate { rotation: 180 } displaced: Transition { Anim { property: "y" } } move: Transition { Anim { property: "y" } } Component.onCompleted: { positionViewAtBeginning(); } onAtYBeginningChanged: { if (atYBeginning) userScrolledUp = false; } onContentHeightChanged: { if (userScrolledUp && Chat.busy) { const delta = contentHeight - lastCHeight; contentY += delta; } lastCHeight = contentHeight; } onMovingChanged: { if (moving) userScrolledUp = !atYBeginning; } Anim { id: scrollAnim property: "contentY" target: list type: Anim.DefaultEffects to: list.originY } WheelInverter { target: list } } // Trigger the load even before we're ready to show the list. Component.onCompleted: if (root.chatData) root.chatData.messagesModel } EmptyBackground { id: emptyState anchors.fill: parent spacing: Tokens.spacing.small visible: root.chatData.messageCount < 1 } Item { anchors.bottom: input.top anchors.bottomMargin: Tokens.spacing.extraLarge anchors.horizontalCenter: parent.horizontalCenter implicitHeight: scrollToBottom.implicitHeight implicitWidth: scrollToBottom.implicitWidth scale: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0 visible: scale > 0 Behavior on scale { Anim {} } Elevation { anchors.fill: parent level: 2 radius: scrollToBottom.radius } IconButton { id: scrollToBottom anchors.centerIn: parent font.pointSize: Tokens.font.size.large icon: "arrow_downward" isRound: true padding: Tokens.padding.extraSmall type: IconButton.Tonal onClicked: { listLoader.item.scrollToBottom(); } } } ChatInput { id: input anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right 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) focus: true placeholderText: qsTr("Send a message") sendIcon.font.pointSize: Tokens.font.size.large sendIcon.icon: "arrow_upward" sendIcon.padding: Tokens.padding.extraSmall Component.onCompleted: root.focusInput() Keys.onPressed: e => { if (e.key == Qt.Key_Return) { if (!(e.modifiers & Qt.ShiftModifier)) { root.send(text); e.accepted = true; } } } onSendPressed: root.send(text) } }