better support for opening chat in floating window

This commit is contained in:
2026-08-28 14:15:57 +02:00
parent 8a66c3b064
commit 327270f3fa
14 changed files with 419 additions and 168 deletions
@@ -0,0 +1,132 @@
import QtQuick
import ZShell.Llm
import ZShell.Config
import qs.Components
import qs.Services
import qs.Modules.Notifications.Sidebar.Chat
Item {
id: root
property int animOff
property Item currentItem
property bool loading: false
property int lastIdx: -1
readonly property Component conversationComp: ChatContent {}
function loadConversation(chat): void {
if (currentItem) {
currentItem.destroy();
currentItem = null;
}
if (!chat)
return;
root.loading = true;
const incubator = root.conversationComp.incubateObject(container, {
chatData: chat
});
const attach = () => {
incubator.object.anchors.fill = container;
currentItem = incubator.object;
root.loading = false;
enterAnim.start();
};
if (incubator.status === Component.Ready)
attach();
else
incubator.onStatusChanged = status => {
if (status === Component.Ready)
attach();
};
}
Item {
id: container
anchors.fill: parent
layer.enabled: opacity < 1
objectName: "ConversationContainer"
Component.onCompleted: {
if (ChatState.chatSession)
root.loadConversation(ChatState.chatSession);
}
}
LoadingIndicator {
anchors.centerIn: parent
implicitSize: Tokens.font.size.extraLarge * 4
opacity: root.loading ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {}
}
}
Connections {
function onChatSessionChanged(): void {
exitAnim.complete();
enterAnim.complete();
root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1);
root.lastIdx = ChatState.currentIdx;
exitAnim.start();
}
target: ChatState
}
SequentialAnimation {
id: exitAnim
Anim {
property: "opacity"
target: container
to: 0
type: Anim.DefaultEffects
}
ScriptAction {
script: root.loadConversation(ChatState.chatSession)
}
}
SequentialAnimation {
id: enterAnim
PropertyAction {
property: "topMargin"
target: container.anchors
value: root.animOff
}
PropertyAction {
property: "bottomMargin"
target: container.anchors
value: -root.animOff
}
ParallelAnimation {
Anim {
from: 0
property: "opacity"
target: container
to: 1
type: Anim.SlowEffects
}
Anim {
properties: "topMargin,bottomMargin"
target: container.anchors
to: 0
type: Anim.SlowEffects
}
}
}
}
@@ -5,28 +5,28 @@ import qs.Components
import qs.Services
ColumnLayout {
id: root
id: root
spacing: Tokens.spacing.small
spacing: Tokens.spacing.small
Item {
Layout.fillHeight: true
}
Item {
Layout.fillHeight: true
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant
font.pointSize: 36
text: "chat"
}
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")
}
CustomText {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("Send a message to get started")
}
Item {
Layout.fillHeight: true
}
Item {
Layout.fillHeight: true
}
}
@@ -11,7 +11,6 @@ Item {
property color mathColor: Colors.palette.m3onSurface
property real fontSize: Tokens.font.size.large
implicitWidth: Math.max(fallbackText.implicitWidth, root.width)
implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2
LlmMathText {
@@ -17,7 +17,8 @@ MouseArea {
required property ChatMessage modelData
property bool reasoningExpanded: false
readonly property var blocks: blockify(current.segments)
property real savedContentY: -1
property real savedOffset: -1
property bool stickActive: false
function handleReasoningToggle(expanded: bool): void {
const view = root.ListView.view;
@@ -25,14 +26,16 @@ MouseArea {
return;
if (expanded) {
root.savedContentY = view.contentY;
root.savedOffset = view.contentY - root.y;
root.reasoningExpanded = true;
} else {
root.reasoningExpanded = false;
if (root.savedContentY !== -1) {
restoreAnim.to = root.savedContentY;
root.stickActive = false;
settleTimer.stop();
if (!isNaN(root.savedOffset)) {
restoreAnim.to = root.y + root.savedOffset;
restoreAnim.restart();
root.savedContentY = -1;
root.savedOffset = NaN;
}
}
}
@@ -69,6 +72,30 @@ MouseArea {
preventStealing: false
implicitHeight: layout.implicitHeight + Tokens.padding.medium
Timer {
id: settleTimer
interval: 16
onTriggered: {
root.stickActive = false;
}
}
onImplicitHeightChanged: {
if (root.reasoningExpanded) {
root.stickActive = true;
settleTimer.restart();
const view = root.ListView.view;
if (!view)
return;
const bottomEdge = root.y + root.height + Tokens.padding.large * 2;
const viewportBottom = view.contentY + view.height;
if (bottomEdge > viewportBottom)
view.contentY = bottomEdge - view.height;
}
}
Behavior on current {
id: changeAnim
@@ -119,14 +146,6 @@ MouseArea {
}
}
Binding {
property: "contentY"
restoreMode: Binding.RestoreNone
target: root.ListView.view
value: root.y + root.height + Tokens.padding.large * 2 - root.ListView.view.height
when: root.reasoningExpanded && root.ListView.view && (root.y + root.height + Tokens.padding.large * 2 > root.ListView.view.contentY + root.ListView.view.height)
}
Anim {
id: restoreAnim