pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import Quickshell import ZShell.Components import ZShell.Config import ZShell.Llm import qs.Modules.Notifications.Sidebar.Chat import qs.Components import qs.Services MouseArea { id: root required property int index required property Item rootParent required property ChatMessage modelData required property ChatListView chatList readonly property bool isUser: modelData.role === ChatMessage.Role.User readonly property var blocks: blockify(current.segments, current.error) property ChatGeneration current: modelData.activeGeneration property bool reasoningExpanded: false property real savedOffset: NaN property bool anchored: false function handleReasoningToggle(expanded: bool, block: Item): void { if (!root.rootParent) return; if (expanded) { root.savedOffset = root.rootParent.contentY - root.y; root.anchored = true; chatList.anchorItem(block, ChatListView.TopEdge); settleTimer.start(); } else { settleTimer.stop(); root.anchored = false; chatList.clearAnchor(); if (!isNaN(root.savedOffset)) { chatList.requestRestore(root.y + root.savedOffset); root.savedOffset = NaN; } } } function blockify(segs, errored) { const blocks = []; for (let i = 0; i < segs.length; i++) { const seg = segs[i]; if (seg.type === LlmSegment.Type.Content) { blocks.push({ kind: "content", id: i, segments: [seg] }); } else { const last = blocks.length ? blocks[blocks.length - 1] : null; if (last && last.kind === "process") { last.segments.push(seg); } else { blocks.push({ kind: "process", id: i, segments: [seg] }); } } } if (errored) blocks.push({ kind: "error", id: segs.length }); return blocks; } hoverEnabled: true preventStealing: false implicitHeight: layout.implicitHeight + Tokens.padding.medium + (attachments.visible ? attachments.implicitHeight : 0) onImplicitHeightChanged: if (root.anchored) settleTimer.restart() Timer { id: settleTimer interval: Tokens.anim.durations.expressiveDefaultEffects repeat: false onTriggered: { if (root.anchored) { root.anchored = false; chatList.clearAnchor(); } } } Behavior on current { id: changeAnim property var object: targetProperty readonly property bool next: { const cond = root.modelData.generations.indexOf(current) < root.modelData.generations.indexOf(changeAnim.targetValue); return cond; } animation: SequentialAnimation { ParallelAnimation { Anim { target: root property: "x" to: changeAnim.next ? root.width / 4 : -root.width / 4 from: 0 type: Anim.FastEffects } Anim { target: root property: "opacity" from: 1 to: 0 type: Anim.FastEffects } } PropertyAction {} ParallelAnimation { Anim { target: root property: "x" from: changeAnim.next ? -root.width / 4 : root.width / 4 type: Anim.FastEffects to: 0 } Anim { target: root property: "opacity" from: 0 type: Anim.FastEffects to: 1 } } } } GridView { id: attachments readonly property int columns: ChatState.isWindow ? 5 : 3 anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top 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" } } model: ScriptModel { objectProp: "path" values: { const list = [...root.modelData.attachments]; if (list.length > 0 && list.length < attachments.columns) { while (list.length < attachments.columns) list.unshift(null); } return list; } } 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.extraLarge 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; } } } } } Column { id: layout anchors.top: attachments.bottom anchors.left: parent.left anchors.right: parent.right spacing: Tokens.spacing.medium Repeater { id: segmentRep model: ScriptModel { values: root.blocks objectProp: "id" } delegate: DelegateChooser { role: "kind" DelegateChoice { roleValue: "error" delegate: ErrorBubble { width: root.width current: root.current onDismiss: root.current.dismissError() } } DelegateChoice { roleValue: "process" delegate: ProcessBlock { width: root.width blocks: root.blocks current: root.current rootParent: root.rootParent onExpandedChanged: root.handleReasoningToggle(expanded, this) } } DelegateChoice { roleValue: "content" delegate: ContentBubble { required property var modelData isUser: root.isUser segment: modelData.segments[0] width: root.width repeater: segmentRep message: root.modelData current: root.current hovered: root.containsMouse onEdit: text => { root.modelData.edit(text); if (root.isUser) root.modelData.generate(); } } } } } } }