// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import Quickshell.Widgets import QtQuick import ".." // One turn as chat bubbles, deck-it's Assistant tab look: the question on // the right with the user's avatar, the reply on the left with the // assistant's (its face cropped from the backdrop, Assistant.qml). The reply // bubble holds the thinking line, the tool chips, the reply and an error or // stopped mark. Both texts go through Segments, so the question renders as // markdown too. Column { id: msg required property int index required property string user required property string reply required property string think required property string tools required property string files // JSON: [{name, kind, pages, truncated}] required property string error required property bool live required property bool stopped required property bool expanded required property var model // the row, for setting expanded property var canvas property string running: "" // the tool running now, if any readonly property int av: 32 // ponytail: bubbles fit the raw text's widest line, measured unformatted; // a reply with a code block or a table, or open reasoning, takes the // full width rather than be measured. readonly property real meMax: msg.width * 0.85 - msg.av - 8 readonly property real botMax: msg.width * 0.90 - msg.av - 8 spacing: 10 Row { anchors.right: parent.right spacing: 8 layoutDirection: Qt.RightToLeft visible: msg.user !== "" Avatar { source: msg.canvas ? msg.canvas.fileUrl(msg.canvas.avatar) : "" } Rectangle { width: Math.min(msg.meMax, qProbe.implicitWidth + 32) height: question.height + 20 radius: 16 bottomRightRadius: 4 color: Qt.rgba(Theme.accent.r * 0.85 + Theme.text.r * 0.15, Theme.accent.g * 0.85 + Theme.text.g * 0.15, Theme.accent.b * 0.85 + Theme.text.b * 0.15, 1) Segments { id: question anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: 14; rightMargin: 14; topMargin: 10 } text: msg.user color: Theme.crust } } } // The files that went with the question, as the daemon read them. Flow { width: msg.width - msg.av - 8 spacing: 6 layoutDirection: Qt.RightToLeft visible: msg.files !== "[]" Repeater { model: JSON.parse(msg.files) Rectangle { required property var modelData width: f.implicitWidth + 16 height: f.implicitHeight + 6 radius: height / 2 color: Qt.alpha(Theme.surface, 0.6) Text { id: f anchors.centerIn: parent text: parent.modelData.name + (parent.modelData.pages ? ` · ${parent.modelData.pages} pages` : "") + (parent.modelData.truncated ? " · truncated" : "") color: parent.modelData.truncated ? Theme.yellow : Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } } } } } Row { spacing: 8 visible: msg.live || msg.think !== "" || msg.tools !== "" || msg.reply !== "" || msg.error !== "" || msg.stopped Avatar { id: face readonly property var f: msg.canvas ? msg.canvas.face : [] source: f.length ? msg.canvas.fileUrl(msg.canvas.backdrop) : "" crop: f } Rectangle { id: bubble readonly property bool wide: msg.expanded || /```|^\|/m.test(msg.reply) width: bubble.wide ? msg.botMax : Math.min(msg.botMax, Math.max(rProbe.implicitWidth, 120) + 30) height: inner.height + 20 radius: 16 bottomLeftRadius: 4 color: Qt.alpha(Theme.surface, 0.88) border.width: 1 border.color: Theme.surfaceAlt Column { id: inner anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: 14; rightMargin: 14; topMargin: 10 } spacing: 6 // Three dots bouncing in turn while the reply has no words yet. Row { id: dots visible: msg.live && msg.think === "" && msg.tools === "" && msg.reply === "" height: 16 spacing: 5 Repeater { model: 3 Rectangle { required property int index width: 7 height: 7 radius: 3.5 color: Theme.subtext y: 8 opacity: 1 - (y - 2) / 12 SequentialAnimation on y { running: dots.visible loops: Animation.Infinite PauseAnimation { duration: index * 150 } NumberAnimation { to: 2; duration: 180; easing.type: Easing.InOutSine } NumberAnimation { to: 8; duration: 180; easing.type: Easing.InOutSine } PauseAnimation { duration: 840 - index * 150 } } } } } // Collapsed by default; a click shows the reasoning as plain text. Text { visible: msg.think !== "" text: (msg.expanded ? "▾ " : "▸ ") + (msg.live && msg.reply === "" ? "thinking…" : "thinking") color: Theme.overlay font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: msg.model.expanded = !msg.expanded } } Text { width: inner.width visible: msg.expanded && msg.think !== "" text: msg.think textFormat: Text.PlainText wrapMode: Text.Wrap color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } Flow { width: inner.width spacing: 6 visible: msg.tools !== "" Repeater { model: msg.tools ? msg.tools.split(",") : [] Rectangle { required property string modelData readonly property bool lit: msg.live && msg.running === modelData property int elapsed: 0 width: chip.implicitWidth + 14 height: chip.implicitHeight + 6 radius: height / 2 color: Qt.alpha(Theme.surface, 0.9) opacity: lit ? 1 : 0.55 // A lit chip alone looks frozen on a clone that takes a // minute, so the running tool counts seconds. onLitChanged: if (!lit) elapsed = 0 Timer { running: parent.lit interval: 1000 repeat: true onTriggered: parent.elapsed++ } Text { id: chip anchors.centerIn: parent text: parent.modelData + (parent.lit ? " · " + parent.elapsed + "s" : "") color: parent.lit ? Theme.text : Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } } } } } Segments { width: inner.width text: msg.reply visible: msg.reply !== "" } Text { visible: msg.error !== "" || msg.stopped width: inner.width text: msg.error !== "" ? msg.error : "stopped" color: msg.error !== "" ? Theme.red : Theme.overlay wrapMode: Text.Wrap font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } } } } // Unformatted twins of the texts, for the bubbles' widths. Text { id: qProbe; visible: false; text: msg.user; textFormat: Text.PlainText; font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } Text { id: rProbe; visible: false; text: msg.reply; textFormat: Text.PlainText; font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } // A round avatar: a picture filling it, or with crop ([centre x, centre // y, size] in the picture's pixels) that square of it. No picture: a // plain circle. component Avatar: ClippingRectangle { id: a property string source: "" property var crop: [] width: msg.av height: msg.av radius: msg.av / 2 color: Theme.surfaceAlt Image { readonly property real k: a.crop.length ? a.width / a.crop[2] : 0 source: a.source visible: status === Image.Ready mipmap: true fillMode: a.crop.length ? Image.Stretch : Image.PreserveAspectCrop width: a.crop.length ? implicitWidth * k : a.width height: a.crop.length ? implicitHeight * k : a.height x: a.crop.length ? -(a.crop[0] - a.crop[2] / 2) * k : 0 y: a.crop.length ? -(a.crop[1] - a.crop[2] / 2) * k : 0 } } }