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 send(): void { if (Chat.busy || input.text.trim() === "") return; following = true; Chat.send(chatData.id, input.text); input.text = ""; } 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 onClicked: root.requestClose() } CustomText { Layout.fillWidth: true elide: Text.ElideRight font.pointSize: Tokens.font.size.larger text: qsTr(root.chatData.title) } } VerticalFadeListView { id: list readonly property real bottomThreshold: Tokens.spacing.extraSmall property bool stickToBottom: true function scrollToEnd(animated: bool): void { const target = Math.max(0, list.contentHeight - list.height); if (!animated) { scrollAnim.stop(); list.contentY = target; return; } scrollAnim.to = target; scrollAnim.restart(); } anchors.bottom: inputRow.top anchors.bottomMargin: Tokens.spacing.medium anchors.left: parent.left anchors.right: parent.right anchors.top: header.bottom cacheBuffer: height * 20 clip: true currentIndex: messages.values.length - 1 spacing: Tokens.spacing.medium CustomScrollBar.vertical: CustomScrollBar { flickable: list } 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 textFormat: CustomText.MarkdownText 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: scrollToEnd(false) onContentHeightChanged: { if (list.stickToBottom) list.scrollToEnd(false); } onMovementEnded: { list.stickToBottom = (list.contentY >= list.contentHeight - list.height - list.bottomThreshold); } onMovementStarted: list.stickToBottom = false Anim { id: scrollAnim property: "contentY" target: list type: Anim.DefaultEffects } } 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 } } IconButton { id: scrollToBottom anchors.bottom: inputRow.top anchors.bottomMargin: Tokens.spacing.extraLarge anchors.horizontalCenter: parent.horizontalCenter font.pointSize: Tokens.font.size.large icon: "arrow_downward" isRound: true padding: Tokens.padding.extraSmall scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 ? 1 : 0 type: IconButton.Filled visible: scale > 0 Behavior on scale { Anim { } } onClicked: { list.stickToBottom = true; list.scrollToEnd(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: Tokens.font.size.normal * 2 icon: Chat.busy ? "stop" : "send" isRound: true type: IconButton.Filled onClicked: Chat.busy ? Chat.stop() : root.send() } } }