// ChatBlockDelegate.qml — Delegate for rendering chat blocks in the ListView import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 Column { id: root spacing: 2 // Required properties from model required property string blockType required property string blockName required property var blockSegments required property string blockRenderedHtml // Reference to parent ListView for scroll control property ListView parentListView: null // Expand state based on block type property bool blockExpanded: { switch (blockType) { case "user": case "assistant": case "tool": case "info": case "retry": case "interrupt": return true default: return false } } // Header color based on block type and theme function headerColor() { var bg = theme.baseColor if (theme.isDark) { switch (blockType) { case "user": return Qt.lighter(bg, 1.8) case "assistant": return Qt.lighter(bg, 1.5) case "reasoning": return Qt.lighter(bg, 1.3) case "call": return Qt.lighter(bg, 1.7) case "tool": return Qt.lighter(bg, 1.2) case "error": return Qt.rgba(0.6, 0.15, 0.15, 0.7) case "info": return Qt.lighter(bg, 1.4) case "retry": return Qt.rgba(0.6, 0.5, 0.0, 0.5) case "interrupt": return "#3d2e00" default: return bg } } else { switch (blockType) { case "user": return Qt.darker(bg, 1.12) case "assistant": return Qt.darker(bg, 1.06) case "reasoning": return Qt.darker(bg, 1.03) case "call": return Qt.darker(bg, 1.1) case "tool": return Qt.darker(bg, 1.04) case "error": return Qt.rgba(1.0, 0.8, 0.8, 0.7) case "info": return Qt.darker(bg, 1.02) case "retry": return Qt.rgba(1.0, 0.9, 0.6, 0.5) case "interrupt": return "#fff8e1" default: return bg } } } // Header label text based on block type function headerText() { switch (blockType) { case "user": return "\uD83E\uDDD1 You" case "assistant": return "\uD83E\uDD16 Ollie" case "reasoning": return "Reasoning" case "call": return "\u25B6\uFE0F " + blockName case "tool": return "\uD83D\uDDA5\uFE0F " + blockName case "error": return "\u274C Error" case "info": return "\u2139\uFE0F Info" case "retry": return "Retry" case "interrupt": return "\u26A0\uFE0F User Interrupt" default: return "" } } // Clickable header Rectangle { width: root.width height: 22 radius: 3 color: root.headerColor() MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: root.blockExpanded = !root.blockExpanded } RowLayout { anchors.fill: parent anchors.leftMargin: 6 spacing: 4 Label { text: root.blockExpanded ? "▼" : "▶" font.pixelSize: 8 opacity: 0.6 } Label { Layout.fillWidth: true text: root.headerText() font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11 font.bold: true color: theme.textColor opacity: 0.9 } } } // Block content rendered as segments Column { width: root.width visible: root.blockExpanded spacing: 4 // Fast path: single TextEdit for the concatenated HTML. // Diagrams are handled separately via the segments list. TextEdit { id: mainTextEdit width: root.width visible: !hasDiagrams() text: root.blockRenderedHtml textFormat: Text.RichText readOnly: true selectByMouse: true persistentSelection: true wrapMode: TextEdit.Wrap font.family: theme.fontFamily || Qt.application.font.family font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 selectionColor: theme.highlightColor selectedTextColor: theme.highlightedTextColor onActiveFocusChanged: { if (!activeFocus && root.parentListView) root.parentListView.interactive = true } MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton propagateComposedEvents: true cursorShape: Qt.IBeamCursor onPressed: function(mouse) { if (root.parentListView) root.parentListView.interactive = false mouse.accepted = false } onReleased: function(mouse) { if (root.parentListView) root.parentListView.interactive = true mouse.accepted = false } } MouseArea { anchors.fill: parent acceptedButtons: Qt.RightButton cursorShape: Qt.IBeamCursor onClicked: { if (!mainTextEdit._menu) { mainTextEdit._menu = contextMenuComponent.createObject(mainTextEdit) } mainTextEdit._menu.popup() } } property var _menu: null } // Diagram path: use Repeater for blocks that contain diagrams Repeater { model: hasDiagrams() ? root.blockSegments : [] delegate: Loader { width: root.width active: modelData !== null && modelData !== undefined sourceComponent: (modelData && modelData.type === "diagram") ? diagramComponent : textComponent property var segmentData: modelData || {} } } } function hasDiagrams() { if (!root.blockSegments) return false for (var i = 0; i < root.blockSegments.length; i++) { if (root.blockSegments[i] && root.blockSegments[i].type === "diagram") return true } return false } // Text segment component Component { id: textComponent TextEdit { id: textEdit width: root.width text: segmentData.html || "" textFormat: Text.RichText readOnly: true selectByMouse: true persistentSelection: true wrapMode: TextEdit.Wrap font.family: theme.fontFamily || Qt.application.font.family font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 selectionColor: theme.highlightColor selectedTextColor: theme.highlightedTextColor onActiveFocusChanged: { if (!activeFocus && root.parentListView) root.parentListView.interactive = true } // Left-click: disable ListView flick while selecting text MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton propagateComposedEvents: true cursorShape: Qt.IBeamCursor onPressed: function(mouse) { if (root.parentListView) root.parentListView.interactive = false mouse.accepted = false } onReleased: function(mouse) { if (root.parentListView) root.parentListView.interactive = true mouse.accepted = false } } // Right-click: lazy context menu MouseArea { anchors.fill: parent acceptedButtons: Qt.RightButton cursorShape: Qt.IBeamCursor onClicked: { if (!textEdit._menu) { textEdit._menu = contextMenuComponent.createObject(textEdit) } textEdit._menu.popup() } } property Menu _menu: null Component.onDestruction: { if (_menu) _menu.destroy() } } } // Shared context menu component (created on demand) Component { id: contextMenuComponent Menu { id: ctxMenu property TextEdit target: parent MenuItem { text: "Copy" enabled: ctxMenu.target ? ctxMenu.target.selectedText.length > 0 : false onTriggered: if (ctxMenu.target) ctxMenu.target.copy() } MenuItem { text: "Select All" onTriggered: if (ctxMenu.target) ctxMenu.target.selectAll() } } } // Diagram segment component Component { id: diagramComponent Rectangle { id: diagramRect width: root.width height: diagramContent.height + diagramHeader.height + 8 color: "transparent" border.color: theme.midColor border.width: 1 radius: 4 property bool showSource: false RowLayout { id: diagramHeader anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right anchors.margins: 4 height: 24 Label { text: segmentData.lang || "diagram" font.pointSize: theme.fontSize > 0 ? theme.fontSize - 1 : 11 color: theme.textColor opacity: 0.7 } Item { Layout.fillWidth: true } Button { text: diagramRect.showSource ? "📊" : "" font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10 implicitWidth: 32 implicitHeight: 20 ToolTip.visible: hovered ToolTip.text: diagramRect.showSource ? "Show diagram" : "Show source" onClicked: diagramRect.showSource = !diagramRect.showSource } Button { text: "📋" font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10 implicitWidth: 32 implicitHeight: 20 ToolTip.visible: hovered ToolTip.text: diagramRect.showSource ? "Copy source" : "Copy image" onClicked: { if (diagramRect.showSource) { clipboard.copyText(segmentData.source) } else if (segmentData.svg) { clipboard.copyImage(segmentData.svg) } } } } Item { id: diagramContent anchors.top: diagramHeader.bottom anchors.left: parent.left anchors.right: parent.right anchors.margins: 4 height: diagramRect.showSource ? sourceEdit.height : imageColumn.height TextEdit { id: sourceEdit width: parent.width visible: diagramRect.showSource text: segmentData.html || "" textFormat: Text.RichText readOnly: true selectByMouse: true persistentSelection: true wrapMode: TextEdit.Wrap font.family: theme.fontFamily || Qt.application.font.family font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 selectionColor: theme.highlightColor selectedTextColor: theme.highlightedTextColor MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton propagateComposedEvents: true cursorShape: Qt.IBeamCursor onPressed: function(mouse) { if (root.parentListView) root.parentListView.interactive = false mouse.accepted = false } onReleased: function(mouse) { if (root.parentListView) root.parentListView.interactive = true mouse.accepted = false } } } Column { id: imageColumn width: parent.width visible: !diagramRect.showSource Rectangle { visible: segmentData.svg && !segmentData.loading color: "white" width: diagramImg.width height: diagramImg.height Image { id: diagramImg source: segmentData.svg || "" fillMode: Image.PreserveAspectFit width: Math.min(implicitWidth, imageColumn.width) } } Label { visible: segmentData.loading text: "Loading " + (segmentData.lang || "diagram") + "..." font.italic: true color: theme.textColor opacity: 0.7 } } } } } }