Files
z-bar-qt/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml
T
zach 99949ff255
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Failing after 11s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Failing after 31s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m37s
Rust / fmt (pull_request) Successful in 27s
Rust / clippy (pull_request) Successful in 58s
new chatlistview component, file attachments, performance & primitive test
2026-09-17 15:20:41 +02:00

352 lines
7.1 KiB
QML

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
font.pointSize: Tokens.font.size.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();
}
}
}
}
}
}
}