pragma ComponentBehavior: Bound import QtQuick import Quickshell 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 // 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 readonly property bool atNewestEnd: !listLoader.item || listLoader.item.contentY < 4 property var pendingAttachments: [] function attachFiles(): void { Files.pickFiles().then(files => { if (!files || files.length === 0) return; const added = chatData.addAttachments(files); root.pendingAttachments = root.pendingAttachments.concat(added); }); } function send(text: string): void { if (text.trim() === "" && root.pendingAttachments.length === 0) return; root.scrollToLatest(); chatData.sendMessage(text, root.pendingAttachments); input.text = ""; root.pendingAttachments = []; } function scrollToLatest(): void { if (!listLoader.item || root.atNewestEnd) return; scrollAnim.stop(); scrollAnim.start(); } 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(); } } Anim { id: scrollAnim target: listLoader.item property: "contentY" to: 0 type: Anim.DefaultEffects onFinished: if (target) target.contentY = 0 } 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: Tokens.font.title.medium text: qsTr(root.chatData?.title || ChatState.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: Flickable { id: flick anchors.fill: parent clip: true contentWidth: width contentHeight: list.layoutHeight transform: Rotation { origin.y: flick.height / 2 origin.x: flick.width / 2 angle: 180 } WheelInverter { target: flick } Anim { id: restoreAnim target: flick type: Anim.DefaultEffects property: "contentY" } ChatListView { id: list width: flick.width height: flick.height yAnimDuration: Tokens.anim.durations.expressiveDefaultEffects asynchronous: true cacheBuffer: 50 estimatedHeight: 100 readyDelay: 5 removeDuration: Tokens.anim.durations.normal spacing: 0 model: root.chatData.messagesModel useCustomViewport: true viewport: { tWatcher.transform; return Qt.rect(0, flick.contentY, width, flick.height); } onViewportAdjustNeeded: delta => { restoreAnim.stop(); flick.contentY += delta; } onViewportRestoreRequested: target => { restoreAnim.to = target; restoreAnim.start(); } delegate: MessageDelegate { id: message rootParent: flick chatList: list ChatListView.preferredHeight: implicitHeight ChatListView.trackViewport: true ChatListView.visibleHeight: ChatListView.removing ? 0 : implicitHeight opacity: ChatListView.removing || ChatListView.adding ? 0 : 1 transform: Rotation { origin.y: message.height / 2 origin.x: message.width / 2 angle: 180 } Behavior on y { enabled: message.ChatListView.ready Anim { id: yAnim type: Anim.DefaultEffects } } Behavior on x { Anim { type: Anim.DefaultEffects } } Behavior on opacity { Anim { type: Anim.DefaultEffects } } } } CustomScrollBar.vertical: CustomScrollBar { id: scrollBar flickable: flick parent: flick.parent anchors.top: flick.top anchors.right: flick.right anchors.bottom: flick.bottom inverted: true transform: Rotation { origin.y: flick.height / 2 origin.x: scrollBar.width / 2 angle: 180 axis { y: 0 x: 1 z: 0 } } } TransformWatcher { id: tWatcher a: flick.contentItem b: 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: root.atNewestEnd || !listLoader.item || listLoader.item.contentHeight <= listLoader.item.height ? 0 : 1 visible: scale > 0 Behavior on scale { Anim {} } Elevation { anchors.fill: parent level: 2 radius: scrollToBottom.radius } IconButton { id: scrollToBottom anchors.centerIn: parent font: Tokens.font.icon.large icon: "arrow_downward" isRound: true padding: Tokens.padding.extraSmall type: IconButton.Tonal onClicked: { root.scrollToLatest(); } } } GridView { id: attachments readonly property int columns: ChatState.isWindow ? 5 : 3 anchors.left: parent.left anchors.right: parent.right anchors.bottom: input.top anchors.bottomMargin: Tokens.spacing.medium flow: GridView.LeftToRight verticalLayoutDirection: GridView.BottomToTop cellWidth: width / columns cellHeight: cellWidth implicitHeight: Math.ceil(count / columns) * cellHeight displaced: Transition { Anim { property: "x" } Anim { property: "y" } } add: Transition { id: addTrans Anim { property: "y" from: addTrans.ViewTransition.destination.y + attachments.cellHeight / 4 } Anim { property: "opacity" from: 0 to: 1 } } remove: Transition { id: removeTrans Anim { property: "y" to: removeTrans.ViewTransition.destination.y + attachments.cellHeight / 4 } Anim { property: "opacity" from: 1 to: 0 } } model: ScriptModel { objectProp: "path" values: root.pendingAttachments } delegate: Item { id: thumb required property var modelData function fileIcon(mime): string { if (mime.startsWith("archive/") || mime === "application/zip") return "folder_zip"; if (mime === "application/pdf") return "picture_as_pdf"; if (mime.startsWith("audio/")) return "audio_file"; if (mime.startsWith("video/")) return "video_file"; if (mime.startsWith("text/")) return "description"; return "insert_drive_file"; } width: attachments.cellWidth height: width opacity: modelData ? 1 : 0 CustomClippingRect { anchors.fill: parent anchors.margins: Tokens.spacing.extraSmall radius: Tokens.rounding.medium color: Colors.tPalette.m3surfaceContainer FadeImage { id: attImage anchors.fill: parent fillMode: FadeImage.PreserveAspectCrop asynchronous: true source: visible ? thumb.modelData?.path ?? "" : "" visible: thumb.modelData?.isImage ?? false } MaterialIcon { anchors.centerIn: parent fontStyle: Tokens.font.icon.large text: thumb.fileIcon(thumb.modelData?.mimeType ?? "") visible: !thumb.modelData?.isImage ?? false } Loader { id: attIndicatorLoader anchors.centerIn: parent opacity: 0 active: opacity > 0 && (thumb.modelData?.isImage ?? false) sourceComponent: CustomRect { implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 color: Colors.palette.m3primaryContainer radius: Tokens.rounding.full LoadingIndicator { id: attLoadingIndicator anchors.centerIn: parent containsIcon: true implicitSize: thumb.implicitWidth * 0.4 } } Behavior on opacity { Anim { type: Anim.DefaultEffects } } } Timer { id: attLoadDebounceTimer interval: 100 onTriggered: { if (attImage.status !== Image.Ready) attIndicatorLoader.opacity = 1; } } MouseArea { id: hover anchors.fill: parent hoverEnabled: true cursorShape: undefined IconButton { anchors.right: parent.right anchors.top: parent.top anchors.margins: Tokens.padding.medium icon: "close" visible: opacity > 0 enabled: thumb.opacity > 0 opacity: hover.containsMouse ? 1 : 0 Behavior on opacity { Anim { type: Anim.DefaultEffects } } onClicked: root.pendingAttachments = root.pendingAttachments.filter(a => a !== thumb.modelData) } } } } } ChatInput { id: input anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right anchors.margins: Tokens.padding.medium bg.border.color: Colors.palette.m3outlineVariant bg.color: Colors.tPalette.m3surfaceContainerLowest fastGlow: LlmClient.busy glow.opacity: input.activeFocus && (!LlmClient.busy || ChatState.isWindow) font: Tokens.font.body.small implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) focus: true placeholderText: qsTr("Send a message") sendIcon.font: Tokens.font.icon.medium sendIcon.icon: LlmClient.busy ? "stop" : "arrow_upward" sendIcon.padding: Tokens.padding.extraSmall sendIcon.opacity: text || LlmClient.busy ? 1.0 : 0.5 sendIcon.enabled: text || LlmClient.busy Component.onCompleted: { root.focusInput(); } Keys.onPressed: e => { if (e.key == Qt.Key_Return) { if (!(e.modifiers & Qt.ShiftModifier)) { root.send(text); e.accepted = true; } } if (e.key == Qt.Key_V && e.modifiers & Qt.ControlModifier) { if (root.chatData.clipboardHasAttachment()) { const att = root.chatData.pasteAttachments(); root.pendingAttachments = [...root.pendingAttachments, ...att]; e.accepted = true; } } } onButtonPressed: { if (LlmClient.busy) LlmClient.stop(); else root.send(text); } } }