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
+3 -3
View File
@@ -12,7 +12,7 @@ ScrollBar {
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
readonly property real axisContentPos: isHorizontal ? flickable.contentX : flickable.contentY
readonly property real axisContentPos: isHorizontal ? (flickable.contentX - flickable.originX) : (flickable.contentY - flickable.originY)
readonly property real axisLength: isHorizontal ? root.width : root.height
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
@@ -102,9 +102,9 @@ ScrollBar {
var newPos = contentPosFromThumbStart(thumbStart);
if (root.isHorizontal)
root.flickable.contentX = newPos;
root.flickable.contentX = newPos + root.flickable.originX;
else
root.flickable.contentY = newPos;
root.flickable.contentY = newPos + root.flickable.originY;
}
function visualThumbStart() {
@@ -87,10 +87,6 @@ Item {
signal requestClose
function scrollToBottom(): void {
Qt.callLater(() => listLoader.item?.positionViewAtBeginning());
}
function send(text: string): void {
if (text.trim() === "")
return;
@@ -129,11 +125,15 @@ Item {
isRound: true
type: IconButton.Tonal
enabled: !ChatState.isWindow
visible: enabled
onClicked: root.requestClose()
}
CustomText {
Layout.fillWidth: true
Layout.fillWidth: ChatState.isWindow ? false : true
Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter
elide: Text.ElideRight
font.pointSize: Tokens.font.size.larger
text: qsTr(root.chatData.title)
@@ -151,21 +151,20 @@ Item {
anchors.topMargin: Tokens.spacing.medium
active: root.chatData && root.chatData.loaded
sourceComponent: ListView {
sourceComponent: VerticalFadeListView {
id: list
property bool userScrolledUp: false
property real lastCHeight: 0.0
function scrollToBottom(): void {
scrollAnim.to = 0;
scrollAnim.start();
}
cacheBuffer: height * 20
cacheBuffer: Math.max(height * 20, 0)
clip: true
// fadeAmount: 0.05
// fadeThreshold: Tokens.padding.medium
fadeAmount: 0.05
fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel
spacing: 0
rotation: 180
@@ -238,6 +237,7 @@ Item {
property: "contentY"
target: list
type: Anim.DefaultEffects
to: list.originY
}
WheelInverter {
@@ -255,7 +255,7 @@ Item {
anchors.fill: parent
spacing: Tokens.spacing.small
visible: !root.chatData
visible: root.chatData.messageCount < 1
}
Item {
@@ -2,6 +2,7 @@ import QtQuick
import ZShell.Components
import ZShell.Config
import ZShell.Llm
import qs.Effects
import qs.Components
import qs.Services
@@ -9,12 +10,13 @@ CustomClippingRect {
id: root
property bool expanded: false
property bool highlighted: false
required property ChatSession modelData
signal clicked(content: ChatSession)
signal remove(content: ChatSession)
color: Colors.tPalette.m3surfaceContainer
color: highlighted ? Qt.alpha(Colors.palette.m3primaryContainer, 0.3) : Qt.alpha(Colors.tPalette.m3surfaceContainer, 0.2)
implicitHeight: {
let h = 0;
@@ -33,6 +35,20 @@ CustomClippingRect {
return h;
}
radius: Tokens.rounding.large
clip: true
CustomRect {
id: borderHighlight
anchors.fill: parent
border.width: root.highlighted ? 2 : 0
border.color: Colors.palette.m3secondary
radius: root.radius - border.width + 1
Behavior on border.width {
Anim {}
}
}
Behavior on implicitHeight {
enabled: titleText.readOnly
@@ -49,6 +65,7 @@ CustomClippingRect {
anchors.topMargin: Tokens.padding.large
font.pointSize: Tokens.font.size.extraLarge
text: root.modelData.icon || "check"
color: Colors.palette.m3onSurface
}
Item {
@@ -85,31 +102,73 @@ CustomClippingRect {
}
}
TextEditBase {
id: titleText
Item {
id: titleWrapper
anchors.left: parent.left
anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
anchors.top: parent.top
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
font.pointSize: Tokens.font.size.normal
text: root.modelData.title
readOnly: true
anchors.right: parent.right
implicitHeight: titleText.implicitHeight + Tokens.padding.small * 2
Behavior on anchors.leftMargin {
Anim {}
}
Behavior on anchors.topMargin {
Anim {}
layer.enabled: true
layer.effect: OpacityMask {
maskSource: fadeMask
}
onEditingFinished: root.modelData.title = text
onReadOnlyChanged: {
if (!readOnly) {
this.forceActiveFocus();
cursorPosition = text.length;
} else {
root.forceActiveFocus();
TextFieldBase {
id: titleText
anchors.left: parent.left
anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
anchors.top: parent.top
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
font.pointSize: Tokens.font.size.normal
text: root.modelData.title || qsTr("New chat")
readOnly: true
color: Colors.palette.m3onSurface
Behavior on anchors.leftMargin {
Anim {}
}
Behavior on anchors.topMargin {
Anim {}
}
onEditingFinished: root.modelData.title = text
onReadOnlyChanged: {
if (!readOnly) {
this.forceActiveFocus();
cursorPosition = text.length;
} else {
root.forceActiveFocus();
}
}
}
}
CustomRect {
id: fadeMask
// anchors.top: titleText.top
// anchors.bottom: titleText.bottom
// anchors.left: titleText.left
// anchors.right: parent.right
anchors.fill: titleWrapper
layer.enabled: true
visible: false
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
color: Qt.rgba(1, 1, 1, 1.0)
position: 0.85
}
GradientStop {
color: Qt.rgba(1, 1, 1, 0)
position: 1.0
}
}
}
@@ -151,7 +210,7 @@ CustomClippingRect {
id: timestamp
anchors.left: parent.left
anchors.top: titleText.bottom
anchors.top: title.bottom
anchors.topMargin: Tokens.spacing.extraSmall
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small
@@ -188,7 +247,7 @@ CustomClippingRect {
font.pointSize: Tokens.font.size.small
maximumLineCount: 1
opacity: !root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? ""
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet")
Behavior on y {
Anim {}
@@ -213,7 +272,7 @@ CustomClippingRect {
color: Colors.palette.m3onSurfaceVariant
maximumLineCount: 5
opacity: root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? ""
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? qsTr("No messages yet")
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on y {
@@ -286,7 +345,7 @@ CustomClippingRect {
implicitHeight: expandIcon.implicitHeight
implicitWidth: expandIcon.implicitHeight
radius: Tokens.rounding.full
color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 3)
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
StateLayer {
onClicked: root.expanded = !root.expanded
@@ -298,6 +357,7 @@ CustomClippingRect {
anchors.centerIn: parent
anchors.verticalCenterOffset: root.expanded ? -1 : 1
rotation: root.expanded ? 180 : 0
color: Colors.palette.m3onSurface
text: "expand_more"
Behavior on anchors.verticalCenterOffset {
@@ -13,9 +13,10 @@ Item {
property alias list: list
property alias model: list.model
property bool highlight: false
signal deleteChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession, index: int)
signal newChatRequest
CustomText {
@@ -40,16 +41,48 @@ Item {
anchors.right: parent.right
anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.medium
cacheBuffer: height * 2
clip: true
spacing: Tokens.spacing.medium
add: Transition {
Anim {
from: list.width
property: "x"
}
}
remove: Transition {
Anim {
to: list.width
property: "x"
}
Anim {
to: 0
property: "opacity"
}
}
displaced: Transition {
Anim {
property: "y"
}
}
move: Transition {
Anim {
property: "y"
}
}
delegate: ChatDelegate {
id: chat
implicitWidth: ListView.view.width
required property int index
onClicked: content => root.loadChatRequest(content)
implicitWidth: ListView.view.width
highlighted: root.highlight && ChatState.chatSession === modelData
onHighlightedChanged: if (highlighted)
list.currentIndex = index
onClicked: content => root.loadChatRequest(content, index)
onRemove: content => root.deleteChatRequest(content)
}
}
@@ -58,12 +91,12 @@ Item {
id: newChatBtn
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.small
anchors.right: parent.right
anchors.margins: Tokens.padding.small
padding: 8
font.pointSize: Math.round(18 * 1.2)
icon: "add"
padding: 8
radius: Tokens.rounding.full
isRound: true
onClicked: {
root.newChatRequest();
@@ -18,45 +18,35 @@ Item {
Component.onCompleted: {
if (ChatState.inChat) {
stackLoader.item?.push(chatList);
stackLoader.item?.push(chatContent, {
stack.push(chatList);
stack.push(chatContent, {
"chatData": ChatState.chatSession
});
} else
stackLoader.item?.push(chatList);
stack.push(chatList);
}
Loader {
id: stackLoader
active: !ChatState.isWindow || root.width <= (ChatState.screen.width / 4)
StackView {
id: stack
anchors.fill: parent
}
sourceComponent: StackView {
id: stack
IconButton {
anchors.left: parent.left
anchors.bottom: parent.bottom
icon: "open_in_new"
anchors.margins: Tokens.padding.small
isRound: true
padding: 8
font.pointSize: Math.round(18 * 1.2)
visible: !ChatState.isWindow
onClicked: {
Detach.create();
Visibilities.getForActive().sidebar = false;
}
}
Loader {
id: sidebarViewLoader
active: ChatState.isWindow && root.width > (ChatState.screen.width / 4)
anchors.fill: parent
sourceComponent: SidebarView {}
}
// IconButton {
// anchors.right: parent.right
// icon: "close"
// visible: !ChatState.isWindow
//
// onClicked: {
// Detach.create();
// Visibilities.getForActive().sidebar = false;
// }
// }
Component {
id: chatList
@@ -68,8 +58,8 @@ Item {
onDeleteChatRequest: chat => {
Chat.chats.remove(chat);
}
onLoadChatRequest: chat => {
stackLoader.item?.push(chatContent, {
onLoadChatRequest: (chat, index) => {
stack.push(chatContent, {
"chatData": chat
});
ChatState.inChat = true;
@@ -77,7 +67,7 @@ Item {
}
onNewChatRequest: {
const data = Chat.chats.insert();
stackLoader.item?.push(chatContent, {
stack.push(chatContent, {
"chatData": data
});
ChatState.inChat = true;
@@ -91,7 +81,7 @@ Item {
ChatContent {
onRequestClose: {
stackLoader.item?.pop();
stack.pop();
ChatState.inChat = false;
}
}
@@ -8,6 +8,8 @@ Singleton {
id: root
property ChatSession chatSession
readonly property string title: chatSession ? chatSession.title : "Chat"
property int currentIdx: -1
property bool inChat: false
property bool isWindow: false
property ShellScreen screen
@@ -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
@@ -31,7 +31,7 @@ Singleton {
minimumSize.height: Config.sidebar.sizes.width
minimumSize.width: Config.sidebar.sizes.width
surfaceFormat.opaque: false
title: qsTr("ZShell - Chat")
title: qsTr("ZShell - %1").arg(ChatState.title)
Component.onCompleted: ChatState.screen = screen
onVisibleChanged: {
@@ -41,7 +41,7 @@ Singleton {
}
}
ChatPanel {
SidebarView {
id: chat
anchors.fill: parent
@@ -2,90 +2,109 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Services
Item {
RowLayout {
id: root
property int breakpoint: 700
property alias conversationModel: sidebar.model
property var currentConversation: null // whatever model item is "open"
readonly property bool isWide: root.width >= root.breakpoint
property bool narrowShowsSidebar: true
function closeConversation() {
narrowShowsSidebar = true;
}
function openConversation(conv) {
currentConversation = conv;
narrowShowsSidebar = false; // in narrow mode, jump to the chat
function openConversation(conv: ChatSession, index: int): void {
}
ChatList {
id: sidebar
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: root.isWide ? undefined : parent.right
anchors.top: parent.top
implicitWidth: 20
list.currentIndex: -1
list.highlightFollowsCurrentItem: false
Layout.fillHeight: true
Layout.margins: Tokens.padding.medium
Layout.rightMargin: Tokens.spacing.medium
Layout.maximumWidth: Config.sidebar.sizes.width
Layout.preferredWidth: root.width / 4
Layout.minimumWidth: Config.sidebar.sizes.width / 2
highlight: true
Behavior on anchors.right {
AnchorAnim {}
}
Behavior on implicitWidth {
Anim {}
}
list.highlight: CustomRect {
color: Colors.palette.m3primary
implicitHeight: sidebar.list.currentItem?.implicitHeight ?? 0
implicitWidth: sidebar.list.width
radius: Tokens.rounding.medium
x: sidebar.list.currentItem?.chat.x ?? 0
y: sidebar.list.currentItem?.y ?? 0
Behavior on y {
Anim {
duration: Tokens.anim.durations.small
easing: Tokens.anim.expressiveDefaultEffects
}
}
}
model: ScriptModel {
values: Chat.chats.values
}
anchors.onRightChanged: {
if (anchors.right === undefined)
implicitWidth = 20;
}
onLoadChatRequest: (chat, index) => {
sidebar.list.currentIndex = index;
root.openConversation(chat);
ChatState.currentIdx = index;
ChatState.chatSession = chat;
}
onDeleteChatRequest: chat => {
Chat.chats.remove(chat);
ChatState.currentIdx = -1;
ChatState.chatSession = null;
}
onNewChatRequest: {
const data = Chat.chats.insert();
ChatState.currentIdx = 0;
ChatState.chatSession = data;
}
}
CustomClippingWrapperRect {
anchors.bottom: parent.bottom
anchors.left: sidebar.right
anchors.right: parent.right
anchors.top: parent.top
child: conversationView
Item {
Layout.margins: Tokens.padding.extraLarge
Layout.leftMargin: Tokens.spacing.extraLarge
Layout.topMargin: Tokens.padding.large
Layout.preferredWidth: Config.sidebar.sizes.width * 2
Layout.fillWidth: true
Layout.fillHeight: true
ChatHost {
id: convHost
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: Math.min(parent.width, 800)
clip: true
onImplicitWidthChanged: console.log(implicitWidth)
}
ColumnLayout {
id: noChatLayout
anchors.centerIn: parent
opacity: ChatState.chatSession ? 0 : 1
visible: opacity > 0
Behavior on opacity {
Anim {}
}
MaterialIcon {
Layout.alignment: Qt.AlignCenter
text: "chat_bubble_off"
font.pointSize: Tokens.font.size.extraLarge
color: Colors.tPalette.m3onSurfaceVariant
}
CustomText {
Layout.alignment: Qt.AlignCenter
text: qsTr("Start or create a chat")
color: Colors.tPalette.m3onSurfaceVariant
}
}
}
ChatContent {
Component {
id: conversationView
anchors.fill: root
anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0
chatData: root.currentConversation
ChatContent {}
}
}
@@ -178,9 +178,6 @@ Item {
sourceComponent: MouseArea {
onWheel: event => event.accepted = true
preventStealing: true
acceptedButtons: Qt.NoButton
cursorShape: undefined
enabled: dialogContent.opacity > 0
clip: true
implicitWidth: innerLayout.implicitWidth + innerLayout.anchors.margins * 2
implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins + innerLayout.anchors.bottomMargin
+2 -2
View File
@@ -16,7 +16,7 @@ ConnectedRect {
property string iconText: "open_in_new"
property int verticalPadding: Tokens.padding.small
signal clicked
signal clicked(event: MouseEvent)
signal toggled(checked: bool)
Layout.fillWidth: true
@@ -56,7 +56,7 @@ ConnectedRect {
}
StateLayer {
onClicked: root.clicked()
onClicked: e => root.clicked(e)
}
CustomSwitch {