chore: move sidebar directory to modules

This commit is contained in:
2026-10-23 00:35:20 +02:00
parent 36cddc96c3
commit cbd4e6caa7
40 changed files with 12 additions and 12 deletions
+581
View File
@@ -0,0 +1,581 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
import ZShell.Llm
import qs.Modules.Sidebar.Chat.Content
import qs.Components
import qs.Services
Item {
id: root
property ChatSession chatData
// qmlformat off
readonly property var keys: [
Qt.Key_A,
Qt.Key_B,
Qt.Key_C,
Qt.Key_D,
Qt.Key_E,
Qt.Key_F,
Qt.Key_G,
Qt.Key_H,
Qt.Key_I,
Qt.Key_J,
Qt.Key_K,
Qt.Key_L,
Qt.Key_M,
Qt.Key_N,
Qt.Key_O,
Qt.Key_P,
Qt.Key_Q,
Qt.Key_R,
Qt.Key_S,
Qt.Key_T,
Qt.Key_U,
Qt.Key_V,
Qt.Key_W,
Qt.Key_X,
Qt.Key_Y,
Qt.Key_Z,
Qt.Key_Agrave,
Qt.Key_Aacute,
Qt.Key_Acircumflex,
Qt.Key_Atilde,
Qt.Key_Adiaeresis,
Qt.Key_Aring,
Qt.Key_AE,
Qt.Key_Ccedilla,
Qt.Key_Egrave,
Qt.Key_Eacute,
Qt.Key_Ecircumflex,
Qt.Key_Ediaeresis,
Qt.Key_Igrave,
Qt.Key_Iacute,
Qt.Key_Icircumflex,
Qt.Key_Idiaeresis,
Qt.Key_ETH,
Qt.Key_Ntilde,
Qt.Key_Ograve,
Qt.Key_Oacute,
Qt.Key_Ocircumflex,
Qt.Key_Otilde,
Qt.Key_Odiaeresis,
Qt.Key_Ooblique,
Qt.Key_Ugrave,
Qt.Key_Uacute,
Qt.Key_Ucircumflex,
Qt.Key_Udiaeresis,
Qt.Key_Yacute,
Qt.Key_ydiaeresis,
Qt.Key_THORN,
Qt.Key_ssharp
]
// qmlformat on
signal requestClose
readonly property bool atNewestEnd: !listLoader.item || listLoader.item.contentY < 4
property var pendingAttachments: []
function attachFiles(): void {
Files.pickFiles().then(files => {
if (!files || files.length === 0)
return;
const added = chatData.addAttachments(files);
root.pendingAttachments = root.pendingAttachments.concat(added);
});
}
function send(text: string): void {
if (text.trim() === "" && root.pendingAttachments.length === 0)
return;
root.scrollToLatest();
chatData.sendMessage(text, root.pendingAttachments);
input.text = "";
root.pendingAttachments = [];
}
function scrollToLatest(): void {
if (!listLoader.item || root.atNewestEnd)
return;
scrollAnim.stop();
scrollAnim.start();
}
function focusInput(): void {
Qt.callLater(() => input.forceActiveFocus());
}
onChatDataChanged: {
if (chatData)
focusInput();
}
Keys.onPressed: e => {
if (root.keys.includes(e.key)) {
input.insert(input.length, e.text);
focusInput();
}
}
Anim {
id: scrollAnim
target: listLoader.item
property: "contentY"
to: 0
type: Anim.DefaultEffects
onFinished: if (target)
target.contentY = 0
}
RowLayout {
id: header
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: Tokens.spacing.large
IconButton {
icon: "arrow_back"
inactiveColor: Colors.tPalette.m3surfaceContainerHigh
inactiveOnColor: Colors.palette.m3onSurfaceVariant
isRound: true
type: IconButton.Tonal
enabled: !ChatState.isWindow
visible: enabled
onClicked: root.requestClose()
}
CustomText {
Layout.fillWidth: ChatState.isWindow ? false : true
Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter
elide: Text.ElideRight
font: Tokens.font.title.medium
text: qsTr(root.chatData?.title || ChatState.title)
}
}
Loader {
id: listLoader
anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.medium
anchors.left: parent.left
anchors.right: parent.right
anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.medium
active: root.chatData && root.chatData.loaded
sourceComponent: Flickable {
id: flick
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: list.layoutHeight
transform: Rotation {
origin.y: flick.height / 2
origin.x: flick.width / 2
angle: 180
}
WheelInverter {
target: flick
}
Anim {
id: restoreAnim
target: flick
type: Anim.DefaultEffects
property: "contentY"
}
ChatListView {
id: list
width: flick.width
height: flick.height
yAnimDuration: Tokens.anim.durations.expressiveDefaultEffects
asynchronous: true
cacheBuffer: 50
estimatedHeight: 100
readyDelay: 5
removeDuration: Tokens.anim.durations.normal
spacing: 0
model: root.chatData.messagesModel
useCustomViewport: true
viewport: {
tWatcher.transform;
return Qt.rect(0, flick.contentY, width, flick.height);
}
onViewportAdjustNeeded: delta => {
restoreAnim.stop();
flick.contentY += delta;
}
onViewportRestoreRequested: target => {
restoreAnim.to = target;
restoreAnim.start();
}
delegate: MessageDelegate {
id: message
rootParent: flick
chatList: list
ChatListView.preferredHeight: implicitHeight
ChatListView.trackViewport: true
ChatListView.visibleHeight: ChatListView.removing ? 0 : implicitHeight
opacity: ChatListView.removing || ChatListView.adding ? 0 : 1
transform: Rotation {
origin.y: message.height / 2
origin.x: message.width / 2
angle: 180
}
Behavior on y {
enabled: message.ChatListView.ready
Anim {
id: yAnim
type: Anim.DefaultEffects
}
}
Behavior on x {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
}
CustomScrollBar.vertical: CustomScrollBar {
id: scrollBar
flickable: flick
parent: flick.parent
anchors.top: flick.top
anchors.right: flick.right
anchors.bottom: flick.bottom
inverted: true
transform: Rotation {
origin.y: flick.height / 2
origin.x: scrollBar.width / 2
angle: 180
axis {
y: 0
x: 1
z: 0
}
}
}
TransformWatcher {
id: tWatcher
a: flick.contentItem
b: list
}
}
Component.onCompleted: if (root.chatData)
root.chatData.messagesModel
}
EmptyBackground {
id: emptyState
anchors.fill: parent
spacing: Tokens.spacing.small
visible: root.chatData.messageCount < 1
}
Item {
anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.extraLarge
anchors.horizontalCenter: parent.horizontalCenter
implicitHeight: scrollToBottom.implicitHeight
implicitWidth: scrollToBottom.implicitWidth
scale: root.atNewestEnd || !listLoader.item || listLoader.item.contentHeight <= listLoader.item.height ? 0 : 1
visible: scale > 0
Behavior on scale {
Anim {}
}
Elevation {
anchors.fill: parent
level: 2
radius: scrollToBottom.radius
}
IconButton {
id: scrollToBottom
anchors.centerIn: parent
font: Tokens.font.icon.large
icon: "arrow_downward"
isRound: true
padding: Tokens.padding.extraSmall
type: IconButton.Tonal
onClicked: {
root.scrollToLatest();
}
}
}
GridView {
id: attachments
readonly property int columns: ChatState.isWindow ? 5 : 3
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.medium
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"
}
}
add: Transition {
id: addTrans
Anim {
property: "y"
from: addTrans.ViewTransition.destination.y + attachments.cellHeight / 4
}
Anim {
property: "opacity"
from: 0
to: 1
}
}
remove: Transition {
id: removeTrans
Anim {
property: "y"
to: removeTrans.ViewTransition.destination.y + attachments.cellHeight / 4
}
Anim {
property: "opacity"
from: 1
to: 0
}
}
model: ScriptModel {
objectProp: "path"
values: root.pendingAttachments
}
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
fontStyle: Tokens.font.icon.large
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;
}
}
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: undefined
IconButton {
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Tokens.padding.medium
icon: "close"
visible: opacity > 0
enabled: thumb.opacity > 0
opacity: hover.containsMouse ? 1 : 0
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
onClicked: root.pendingAttachments = root.pendingAttachments.filter(a => a !== thumb.modelData)
}
}
}
}
}
ChatInput {
id: input
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
bg.border.color: Colors.palette.m3outlineVariant
bg.color: Colors.tPalette.m3surfaceContainerLowest
fastGlow: LlmClient.busy
glow.opacity: input.activeFocus && (!LlmClient.busy || ChatState.isWindow)
font: Tokens.font.body.small
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
focus: true
placeholderText: qsTr("Send a message")
sendIcon.font: Tokens.font.icon.medium
sendIcon.icon: LlmClient.busy ? "stop" : "arrow_upward"
sendIcon.padding: Tokens.padding.extraSmall
sendIcon.opacity: text || LlmClient.busy ? 1.0 : 0.5
sendIcon.enabled: text || LlmClient.busy
Component.onCompleted: {
root.focusInput();
}
Keys.onPressed: e => {
if (e.key == Qt.Key_Return) {
if (!(e.modifiers & Qt.ShiftModifier)) {
root.send(text);
e.accepted = true;
}
}
if (e.key == Qt.Key_V && e.modifiers & Qt.ControlModifier) {
if (root.chatData.clipboardHasAttachment()) {
const att = root.chatData.pasteAttachments();
root.pendingAttachments = [...root.pendingAttachments, ...att];
e.accepted = true;
}
}
}
onButtonPressed: {
if (LlmClient.busy)
LlmClient.stop();
else
root.send(text);
}
}
}
+389
View File
@@ -0,0 +1,389 @@
import QtQuick
import ZShell.Components
import ZShell.Config
import ZShell.Llm
import qs.Effects
import qs.Components
import qs.Services
CustomRect {
id: root
property bool expanded: false
property bool highlighted: false
property bool slim: false
required property ChatSession modelData
signal clicked(content: ChatSession)
signal remove(content: ChatSession)
color: highlighted ? Qt.alpha(Colors.palette.m3primaryContainer, 0.3) : Qt.alpha(Colors.tPalette.m3surfaceContainer, 0.2)
implicitHeight: {
let h = 0;
if (slim)
return chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2;
h += infoContainer.implicitHeight;
if (expanded)
h += body.implicitHeight + body.topMargin + actionRow.implicitHeight + actionRow.anchors.topMargin;
else
h += preview.implicitHeight + preview.topMargin;
h += Tokens.padding.small;
const icon = chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2;
if (h < icon)
return icon;
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
Anim {}
}
MaterialIcon {
id: chatIcon
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.large
anchors.top: parent.top
anchors.topMargin: Tokens.padding.large
fontStyle: Tokens.font.icon.extraLarge
text: root.modelData.icon || "check"
color: Colors.palette.m3onSurface
}
Item {
id: infoContainer
anchors.left: chatIcon.right
anchors.leftMargin: Tokens.spacing.medium
anchors.top: parent.top
anchors.right: expandBtn.left
anchors.rightMargin: Tokens.spacing.medium
implicitHeight: {
let h = 0;
h += title.implicitHeight + title.anchors.topMargin;
h += timestamp.implicitHeight + timestamp.anchors.topMargin;
if (root.expanded)
h += created.implicitHeight + created.anchors.topMargin;
return h;
}
opacity: root.slim ? 0 : 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
CustomRect {
id: title
anchors.left: parent.left
anchors.topMargin: Tokens.padding.extraSmall
anchors.top: parent.top
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 1)
implicitHeight: titleText.implicitHeight + Tokens.padding.extraSmall * 2
radius: Tokens.rounding.full
implicitWidth: titleText.implicitWidth + Tokens.padding.large * 2
opacity: titleText.readOnly ? 0 : 1
Behavior on opacity {
Anim {}
}
}
Item {
id: titleWrapper
anchors.left: parent.left
anchors.top: parent.top
anchors.right: parent.right
implicitHeight: titleText.implicitHeight + Tokens.padding.small * 2
layer.enabled: true
layer.effect: OpacityMask {
maskSource: fadeMask
}
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: Tokens.font.body.medium
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.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
}
}
}
CustomText {
id: created
anchors.top: title.bottom
anchors.topMargin: Tokens.spacing.extraSmall
anchors.left: parent.left
anchors.right: parent.right
font: Tokens.font.label.small
color: Colors.palette.m3onSurfaceVariant
text: qsTr("Created on: %1").arg(root.modelData.createdAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP"))
opacity: root.expanded ? 1 : 0
Behavior on opacity {
Anim {}
}
}
CustomText {
id: updated
anchors.top: created.bottom
anchors.topMargin: Tokens.spacing.small
anchors.left: parent.left
font: Tokens.font.label.small
color: Colors.palette.m3onSurfaceVariant
text: qsTr("Updated on: ")
opacity: root.expanded ? 1 : 0
Behavior on opacity {
Anim {}
}
}
CustomText {
id: timestamp
anchors.left: parent.left
anchors.top: title.bottom
anchors.topMargin: Tokens.spacing.extraSmall
color: Colors.palette.m3onSurfaceVariant
font: Tokens.font.label.small
text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")
states: State {
name: "expanded"
when: root.expanded
AnchorChanges {
target: timestamp
anchors.left: updated.right
anchors.top: undefined
anchors.verticalCenter: updated.verticalCenter
}
}
transitions: Transition {
AnchorAnim {}
}
}
}
CustomText {
id: preview
readonly property int topMargin: Tokens.spacing.extraSmall
anchors.left: chatIcon.right
anchors.margins: Tokens.spacing.medium
anchors.right: parent.right
y: infoContainer.implicitHeight + topMargin
elide: Text.ElideRight
color: Colors.palette.m3onSurfaceVariant
font: Tokens.font.body.small
maximumLineCount: 1
opacity: !root.expanded && !root.slim ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet")
Behavior on y {
Anim {}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
CustomText {
id: body
readonly property int topMargin: root.expanded ? Tokens.spacing.medium : Tokens.spacing.extraSmall
anchors.left: chatIcon.right
anchors.margins: Tokens.spacing.medium
anchors.right: parent.right
y: infoContainer.implicitHeight + topMargin
animate: true
color: Colors.palette.m3onSurfaceVariant
maximumLineCount: 5
opacity: root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.content ?? qsTr("No messages yet")
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on y {
Anim {}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
StateLayer {
id: sLayer
property int startY
onClicked: {
root.clicked(root.modelData);
}
}
ButtonRow {
id: actionRow
anchors.top: body.bottom
anchors.left: chatIcon.right
anchors.right: parent.right
anchors.topMargin: Tokens.spacing.small
anchors.leftMargin: Tokens.spacing.medium
anchors.rightMargin: Tokens.padding.medium
spacing: Tokens.spacing.medium
IconButton {
icon: "close"
fillWidth: true
shapeMorph: true
isRound: true
font: Tokens.font.icon.medium
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
inactiveOnColor: Colors.palette.m3onSurfaceVariant
onClicked: root.remove(root.modelData)
}
IconButton {
icon: titleText.readOnly ? "edit" : "check"
label.animate: true
isToggle: true
fillWidth: true
shapeMorph: true
font: Tokens.font.icon.medium
isRound: true
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
inactiveOnColor: Colors.palette.m3onSurfaceVariant
activeColor: Colors.palette.m3secondary
activeOnColor: Colors.palette.m3onSecondary
checked: !titleText.readOnly
onClicked: titleText.readOnly = !titleText.readOnly
}
}
CustomRect {
id: expandBtn
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Tokens.padding.small
implicitHeight: expandIcon.implicitHeight
implicitWidth: expandIcon.implicitHeight
radius: Tokens.rounding.full
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
opacity: root.slim ? 0 : 1
visible: opacity > 0
enabled: visible
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
StateLayer {
onClicked: root.expanded = !root.expanded
}
MaterialIcon {
id: expandIcon
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 {
Anim {}
}
Behavior on rotation {
Anim {}
}
}
}
}
+142
View File
@@ -0,0 +1,142 @@
import QtQuick
import ZShell.Config
import qs.Components
import qs.Services
TextAreaBase {
id: root
readonly property alias bg: bg
readonly property alias sendIcon: sendIcon
readonly property alias glow: glow
property bool fastGlow: false
signal buttonPressed
bottomPadding: Tokens.padding.large
leftPadding: Tokens.padding.extraLarge
rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small
topPadding: Tokens.padding.large
wrapMode: TextAreaBase.Wrap
onFastGlowChanged: {
if (fastGlow) {
resetAnim.stop();
boostAnim.restart();
} else {
boostAnim.stop();
resetAnim.restart();
}
}
background: CustomRect {
id: bg
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainer
radius: Tokens.rounding.extraLarge
GeminiGlow {
id: glow
glowSize: 32
z: 5
opacity: root.activeFocus ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {}
}
SequentialAnimation {
id: boostAnim
NumberAnimation {
target: glow
property: "speed"
to: 4.0
duration: 250
easing.type: Easing.OutCubic
}
NumberAnimation {
target: glow
property: "speed"
to: 2.25
duration: 1500
easing.type: Easing.InOutQuad
}
}
// Ease back down to normal once fastGlow is false
NumberAnimation {
id: resetAnim
target: glow
property: "speed"
to: 1.0
duration: 800
easing.type: Easing.InOutQuad
}
}
StateLayer {
id: stateLayer
cursorShape: Qt.IBeamCursor
enabled: !root.activeFocus
manualPressOverride: tapHandler.pressed
onClicked: root.forceActiveFocus()
}
}
onPressed: {
if (stateLayer.enabled)
stateLayer.press(stateLayer.mouseX, stateLayer.mouseY);
}
CustomText {
id: placeholder
anchors.left: parent.left
anchors.leftMargin: root.leftPadding
anchors.verticalCenter: parent.verticalCenter
color: root.placeholderTextColor
font: root.font
opacity: root.text ? 0 : 1
text: root.placeholderText
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
IconButton {
id: sendIcon
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.small
anchors.verticalCenter: parent.verticalCenter
enabled: root.text
icon: "upward_arrow"
opacity: root.text ? 1 : 0
radius: Tokens.rounding.full
radiusMorph: false
stateLayer.hoverEnabled: enabled
type: IconButton.Text
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
onClicked: root.buttonPressed()
}
TapHandler {
id: tapHandler
}
}
+307
View File
@@ -0,0 +1,307 @@
pragma ComponentBehavior: Bound
import Quickshell
import QtQuick
import QtQuick.Layouts
import ZShell.Llm
import ZShell.Config
import qs.Modules.Sidebar.Chat.Content
import qs.Services
import qs.Components
Item {
id: root
property alias list: list
property alias model: list.model
property bool highlight: false
property bool slim: false
signal requestExpand
signal deleteChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession, index: int)
signal newChatRequest
onSlimChanged: {
if (slim)
modelsContainer.open = false;
}
CustomText {
id: chatHeader
anchors.left: parent.left
anchors.margins: Tokens.padding.extraSmall
anchors.right: parent.right
anchors.top: parent.top
color: Colors.palette.m3outline
font.family: "CaskaydiaCove NF"
font.pointSize: 13
font.weight: 500
text: qsTr("%1 Chat%2").arg(list.count).arg(list.count > 1 ? "s" : "")
}
CustomListView {
id: list
anchors.bottom: modelsContainer.top
anchors.left: parent.left
anchors.right: parent.right
anchors.top: chatHeader.bottom
anchors.topMargin: Tokens.spacing.medium
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
required property int index
implicitWidth: ListView.view.width
highlighted: root.highlight && ChatState.chatSession === modelData
slim: root.slim
onHighlightedChanged: if (highlighted)
list.currentIndex = index
onClicked: content => root.loadChatRequest(content, index)
onRemove: content => root.deleteChatRequest(content)
}
TapHandler {
onTapped: {
root.focus = true;
ChatState.fabExpanded = false;
modelsContainer.expanded = false;
}
}
}
ModelsContainer {
id: modelsContainer
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: fabRoot.left
anchors.margins: Tokens.padding.small
anchors.rightMargin: Tokens.spacing.small
rootParent: root
fabRoot: fabRoot
opacity: root.slim ? 0 : 1
visible: opacity > 0
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
IconButton {
id: fabRoot
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: Tokens.padding.small
padding: 8
font.pointSize: Math.round(18 * 1.2)
icon: root.slim ? "left_panel_open" : "add"
isRound: ChatState.fabExpanded
label.transform: Rotation {
origin.y: fabRoot.label.height / 2
origin.x: fabRoot.label.width / 2
angle: ChatState.fabExpanded && (ChatState.narrowSidebarExpanded && !ChatState.isWide || !ChatState.isWindow) ? 135 : 0
Behavior on angle {
Anim {}
}
}
onClicked: {
if (root.slim && !ChatState.narrowSidebarExpanded) {
root.requestExpand();
return;
}
ChatState.fabExpanded = !ChatState.fabExpanded;
}
Elevation {
anchors.fill: parent
level: fabRoot.stateLayer.containsMouse ? 4 : 3
radius: parent.radius
z: -1
}
}
ColumnLayout {
id: fabMenu
anchors.bottom: fabRoot.top
anchors.right: fabRoot.right
anchors.bottomMargin: Tokens.padding.medium
Repeater {
id: fabRep
model: ListModel {
ListElement {
name: "new chat"
icon: "add"
}
ListElement {
name: "pop out in window"
icon: "open_in_new"
}
}
CustomRect {
id: fabMenuItem
required property var modelData
required property int index
Layout.alignment: Qt.AlignRight
implicitHeight: fabMenuItemInner.implicitHeight + Tokens.padding.medium * 2
radius: sLayer.pressed ? Tokens.rounding.small : implicitHeight / 2
color: Colors.palette.m3primaryContainer
visible: !(modelData.name === "pop out in window" && ChatState.isWindow)
clip: true
Behavior on radius {
Anim {
type: Anim.DefaultEffects
}
}
opacity: 0
states: State {
name: "visible"
when: ChatState.fabExpanded
PropertyChanges {
fabMenuItem.implicitWidth: fabMenuItemInner.implicitWidth + Tokens.padding.large * 2
fabMenuItem.opacity: 1
fabMenuItemInner.opacity: 1
}
}
transitions: [
Transition {
to: "visible"
SequentialAnimation {
PauseAnimation {
duration: (fabRep.count - 1 - fabMenuItem.index) * 50
}
ParallelAnimation {
Anim {
property: "implicitWidth"
type: Anim.FastSpatial
}
Anim {
property: "opacity"
duration: Tokens.anim.durations.small
easing: Tokens.anim.standard
}
}
}
},
Transition {
from: "visible"
SequentialAnimation {
PauseAnimation {
duration: fabMenuItem.index * Tokens.anim.durations.small / 8
}
ParallelAnimation {
Anim {
property: "implicitWidth"
type: Anim.FastSpatial
}
Anim {
property: "opacity"
duration: Tokens.anim.durations.small
easing: Tokens.anim.standard
}
}
}
}
]
RowLayout {
id: fabMenuItemInner
anchors.centerIn: parent
spacing: Tokens.spacing.medium
opacity: 0
MaterialIcon {
text: fabMenuItem.modelData.icon
color: Colors.palette.m3onPrimaryContainer
fontStyle: Tokens.font.icon.large
fill: 1
}
CustomText {
animate: true
text: fabMenuItem.modelData.name
font.capitalization: Font.Capitalize
color: Colors.palette.m3onPrimaryContainer
Layout.preferredWidth: implicitWidth
Behavior on Layout.preferredWidth {
Anim {}
}
}
}
StateLayer {
id: sLayer
onClicked: {
const name = fabMenuItem.modelData.name;
if (name === "new chat")
root.newChatRequest();
else if (name === "pop out in window")
Detach.create();
ChatState.fabExpanded = false;
}
}
}
}
}
}
+83
View File
@@ -0,0 +1,83 @@
pragma ComponentBehavior: Bound
import ZShell.Config
import ZShell.Llm
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Helpers
import qs.Modules.Sidebar
import qs.Components
import qs.Services
Item {
id: root
anchors.fill: parent
anchors.margins: Tokens.padding.small
Component.onCompleted: {
if (ChatState.firstOpen) {
ChatState.openDraft();
stack.push(chatList);
stack.push(chatContent);
ChatState.firstOpen = false;
return;
}
if (ChatState.inChat) {
stack.push(chatList);
stack.push(chatContent);
} else
stack.push(chatList);
}
function openChat(): void {
if (stack.depth < 2)
stack.push(chatContent);
}
StackView {
id: stack
anchors.fill: parent
}
Component {
id: chatList
ChatList {
model: LlmClient.chats
onDeleteChatRequest: chat => {
const wasCurrent = chat === ChatState.chatSession;
LlmClient.chats.remove(chat);
if (wasCurrent)
ChatState.openDraft();
}
onLoadChatRequest: (chat, index) => {
ChatState.chatSession = chat;
openChat();
ChatState.inChat = true;
}
onNewChatRequest: {
ChatState.openDraft();
openChat();
ChatState.inChat = true;
}
}
}
Component {
id: chatContent
ChatContent {
chatData: ChatState.chatSession
onRequestClose: {
stack.pop();
ChatState.inChat = false;
ChatState.openDraft();
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
pragma Singleton
import QtQuick
import Quickshell
import ZShell.Llm
import qs.Services
Singleton {
id: root
property ChatSession chatSession: LlmClient.chats.draft()
readonly property string title: chatSession?.title || "Chat"
property int currentIdx: -1
property bool firstOpen: true
property bool inChat: false
property bool fabExpanded: false
property bool isWindow: false
property bool isWide: false
property bool settledSlim: false
property bool settledWide: false
property bool narrowSidebarExpanded: false
property bool windowIsWide: false
property ShellScreen screen
property bool sidebarVisible: ShellState.forActive().sidebar
function openDraft(): void {
root.chatSession = LlmClient.chats.draft();
root.currentIdx = -1;
}
onSidebarVisibleChanged: if (!sidebarVisible)
fabExpanded = false
}
+118
View File
@@ -0,0 +1,118 @@
import QtQuick
import Quickshell
import ZShell.Config
import ZShell.Components
import ZShell.Llm
import qs.Components
import qs.Services
Item {
id: root
required property TextEdit edit
required property bool hovered
required property bool isUser
required property ChatMessage message
required property bool running
implicitHeight: editButton.implicitHeight
implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small
ButtonRow {
id: generationTools
enabled: visible
visible: !root.isUser
IconButton {
enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== 0
icon: "chevron_left"
type: IconButton.Tonal
isRound: true
onClicked: {
if (!root.edit.readOnly)
root.edit.readOnly = true;
root.message.setActiveGeneration(root.message.activeGenerationIndex - 1);
}
}
IconButton {
enabled: false
icon: `${root.message.activeGenerationIndex + 1}`
label.font: Tokens.font.body.small
type: IconButton.Text
}
IconButton {
enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== root.message.generationCount - 1
icon: "chevron_right"
type: IconButton.Tonal
isRound: true
onClicked: {
if (!root.edit.readOnly)
root.edit.readOnly = true;
root.message.setActiveGeneration(root.message.activeGenerationIndex + 1);
}
}
}
ButtonRow {
id: editTools
anchors.right: parent.right
opacity: !root.running && root.hovered ? 1 : 0
spacing: Tokens.spacing.small
visible: opacity > 0
Behavior on opacity {
Anim {}
}
IconButton {
icon: "refresh"
inactiveColor: Colors.palette.m3secondary
inactiveOnColor: Colors.palette.m3onSecondary
scale: root.edit.readOnly ? 1 : 0
visible: !root.isUser
isRound: true
Behavior on scale {
Anim {}
}
onClicked: {
root.message.retry();
}
}
IconButton {
icon: "content_copy"
inactiveColor: Colors.palette.m3secondary
inactiveOnColor: Colors.palette.m3onSecondary
scale: root.edit.readOnly ? 1 : 0
isRound: true
Behavior on scale {
Anim {}
}
onClicked: Quickshell.clipboardText = root.message.content
}
IconButton {
id: editButton
icon: root.edit.readOnly ? "edit" : "check"
inactiveColor: Colors.palette.m3tertiary
inactiveOnColor: Colors.palette.m3onTertiary
visible: root.isUser
isRound: true
onClicked: {
root.edit.readOnly = !root.edit.readOnly;
}
}
}
}
@@ -0,0 +1,400 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import ZShell.Components
import ZShell.Llm
import ZShell.Blobs
import qs.Components
import qs.Services
Item {
id: root
required property Item rootParent
required property ChatGeneration current
implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2
CustomRect {
id: approvalBg
anchors.fill: parent
radius: Tokens.rounding.medium
color: Colors.palette.m3surface
border.width: 1
border.color: Colors.palette.m3outlineVariant
ColumnLayout {
id: approvalLayout
anchors.fill: parent
anchors.margins: Tokens.padding.medium
spacing: Tokens.spacing.small
CustomText {
id: requestHeader
text: qsTr("Tool call request")
font: Tokens.font.headline.medium
color: Colors.palette.m3onSurface
}
Item {
id: toolsBg
property bool open: false
property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2
property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight)
property int openWidth: root.rootParent.width - Tokens.padding.medium * 2
property real openHeightProg: openHeight / maxHeight
property int collapsedY: 0
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
z: open || dialogTransition.running ? 2 : 0
Layout.fillWidth: true
function reparentWrapper(): void {
const newParent = open ? root.rootParent : toolsBg;
const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2);
toolsBg.collapsedY = toolsWrapper.y;
toolsWrapper.parent = newParent;
toolsWrapper.x = c.x - toolsWrapper.width / 2;
toolsWrapper.y = c.y - toolsWrapper.height / 2;
}
onOpenChanged: {
if (open)
root.rootParent.interactive = false;
else
root.rootParent.interactive = true;
}
BlobGroup {
id: blobGroup
color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer
Behavior on color {
CAnim {}
}
}
MouseArea {
id: backdrop
anchors.fill: parent
enabled: false
hoverEnabled: enabled
preventStealing: true
parent: toolsBg.open ? root.rootParent : toolsBg
onClicked: toolsBg.open = false
}
Item {
id: toolsWrapper
width: toolsBg.width
height: toolBox.implicitHeight
rotation: parent === root.rootParent ? 180 : 0
z: 1
states: State {
name: "open"
when: toolsBg.open
PropertyChanges {
backdrop.enabled: true
toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased
toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased
toolsContent.opacity: 1
toolsWrapper.height: toolsBg.openHeight
toolsWrapper.width: toolsBg.openWidth
toolsWrapper.x: (root.rootParent.width - toolsBg.openWidth) / 2
toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium
elevation.opacity: 1
toolBox.opacity: 0
}
}
transitions: Transition {
id: dialogTransition
SequentialAnimation {
ScriptAction {
script: toolsBg.reparentWrapper()
}
Anim {
properties: "x,y"
}
}
PropertyAction {
property: "enabled"
}
Anim {
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
type: Anim.DefaultEffects
}
Anim {
properties: "width,height"
}
}
Elevation {
id: elevation
anchors.fill: parent
bottomLeftRadius: toolsBackground.bottomLeftRadius
bottomRightRadius: toolsBackground.bottomRightRadius
level: 4
opacity: 0
radius: toolsBackground.radius
transform: Matrix4x4 {
matrix: toolsBackground.deformMatrix
}
}
BlobRect {
id: toolsBackground
anchors.fill: parent
bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
deformScale: 0
group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
}
CustomRect {
id: toolBox
anchors.left: parent.left
anchors.right: parent.right
height: Math.min(implicitHeight, parent.height)
implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2
radius: Tokens.rounding.small
StateLayer {
onClicked: {
toolsBg.open = true;
}
}
ColumnLayout {
id: toolList
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Tokens.padding.medium
Repeater {
id: repeater
model: root.current.pendingToolCalls?.slice(0, 4) ?? []
delegate: RowLayout {
id: toolText
required property var modelData
required property int index
spacing: Tokens.spacing.medium
function toolTarget(seg: var): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
} catch (e) {}
if (a && typeof a === "object") {
const v = a.url ?? a.path;
if (v !== undefined && v !== null)
return String(v);
}
return String(seg.arguments);
}
MaterialIcon {
color: Colors.palette.m3onPrimaryContainer
text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet"
fill: 1
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("%1").arg(toolText.modelData.name)
}
MaterialIcon {
color: Colors.palette.m3onPrimaryContainer
text: "arrow_right_alt"
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData))
}
}
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length)
visible: repeater.count < root.current.pendingToolCalls.length
}
}
}
Loader {
id: toolsContent
active: opacity > 0
anchors.fill: parent
asynchronous: false
opacity: 0
sourceComponent: MouseArea {
onWheel: event => event.accepted = true
preventStealing: true
implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2
VerticalFadeListView {
id: innerLayout
anchors.fill: parent
anchors.margins: Tokens.padding.small
cacheBuffer: height * 10
fadeAmount: 0.1
fadeEnabled: interactive
spacing: 0
clip: true
interactive: contentHeight > toolsBg.maxHeight
model: root.current.pendingToolCalls ?? []
delegate: Item {
id: toolCall
required property var modelData
property bool expanded: false
implicitWidth: CustomListView.view.width
implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins)
function toolTarget(seg: var): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
} catch (e) {}
if (a && typeof a === "object") {
const v = a.url ?? a.path;
if (v !== undefined && v !== null)
return String(v);
}
return String(seg.arguments);
}
Behavior on implicitHeight {
Anim {}
}
RowLayout {
id: collapsedRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
IconButton {
id: toolExpandBtn
font: Tokens.font.icon.medium
icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
rotation: toolCall.expanded ? 180 : 0
type: IconButton.Text
Behavior on rotation {
Anim {}
}
onClicked: toolCall.expanded = !toolCall.expanded
}
CustomText {
id: preview
Layout.fillWidth: true
text: toolCall.modelData.name
}
}
CustomClippingRect {
id: expandedTool
anchors.top: collapsedRow.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
anchors.topMargin: Tokens.spacing.medium
implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2
radius: Tokens.rounding.medium
color: Colors.palette.m3surfaceContainerLow
CustomText {
id: fullToolCall
anchors.fill: parent
anchors.margins: Tokens.padding.medium
text: toolCall.toolTarget(toolCall.modelData)
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
}
}
}
}
}
}
}
}
ButtonRow {
id: confirmationRow
Layout.alignment: Qt.AlignRight
spacing: Tokens.spacing.medium
IconTextButton {
icon: "check_circle"
text: qsTr("Allow")
horizontalPadding: Tokens.padding.large
verticalPadding: Tokens.padding.small
isRound: true
shapeMorph: true
onClicked: root.current.approveTools()
}
IconTextButton {
icon: "cancel"
text: qsTr("Deny")
isRound: true
shapeMorph: true
inactiveColor: Colors.palette.m3error
inactiveOnColor: Colors.palette.m3onError
horizontalPadding: Tokens.padding.large
verticalPadding: Tokens.padding.small
onClicked: root.current.denyTools()
}
}
}
}
}
@@ -0,0 +1,27 @@
import Quickshell
import QtQuick
import ZShell.Config
import qs.Components
import qs.Services
TextEditBase {
id: root
color: Colors.palette.m3onSurface
readOnly: true
textFormat: Text.MarkdownText
font: Tokens.font.body.small
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
onLinkActivated: link => {
Qt.openUrlExternally(link);
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
cursorShape: root.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor
preventStealing: false
hoverEnabled: true
}
}
+147
View File
@@ -0,0 +1,147 @@
import QtQuick
import ZShell.Llm
import ZShell.Config
import qs.Components
import qs.Services
import qs.Modules.Sidebar.Chat
Item {
id: root
property int animOff
property Item currentItem
property bool noAnim: false
property bool loading: false
property int lastIdx: -1
readonly property Component conversationComp: ChatContent {}
signal requestClose
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.top = container.top;
incubator.object.anchors.bottom = container.bottom;
incubator.object.anchors.margins = Tokens.padding.medium;
incubator.object.anchors.horizontalCenter = container.horizontalCenter;
incubator.object.requestClose.connect(root.requestClose);
incubator.object.implicitWidth = Qt.binding(() => Math.max(Math.min(container.width - Tokens.padding.medium * 2, 800), Config.sidebar.sizes.width));
currentItem = incubator.object;
root.loading = false;
if (!noAnim)
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.icon.extraLarge.pointSize * 4
opacity: root.loading ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {}
}
}
Connections {
function onChatSessionChanged(): void {
exitAnim.complete();
enterAnim.complete();
if (root.noAnim) {
root.loadConversation(ChatState.chatSession);
return;
}
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
}
}
}
}
@@ -0,0 +1,296 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import QtQuick.Layouts
import Quickshell
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
CustomRect {
id: root
required property string language
required property string code
property bool copied: false
property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
property color codeHeaderColor: Colors.palette.m3outline
property string html: ""
property bool colored: false
property string requestedCode: ""
property string requestedLanguage: ""
property int highlightToken: 0
function refresh() {
requestedCode = root.code;
requestedLanguage = root.language;
const token = ++root.highlightToken;
CodeHighlighter.highlight(root.code, root.language, root, token);
}
function onHighlightSpans(token, spans) {
if (token !== root.highlightToken)
return;
if (root.code !== root.requestedCode || root.language !== root.requestedLanguage)
return;
html = spans.length ? root.highlightedHtml(root.code, spans) : "";
colored = root.visible && html.length > 0;
}
function roleColor(kind) {
var s = CodeColors.active;
switch (kind) {
case "comment":
return s.comment;
case "string":
return s.string;
case "string.key":
return s.stringKey;
case "number":
case "constant":
return s.number;
case "keyword":
return s.keyword;
case "type":
return s.type;
case "function":
return s.functions;
case "method":
return s.method ?? s.functions;
case "macro":
return s.macro;
case "preproc":
return s.preproc ?? s.macro;
case "operator":
return s.operator ?? s.normal;
case "property":
return s.property ?? s.normal;
case "label":
return s.label;
case "attribute":
return s.attribute ?? s.label;
default:
return s.normal;
}
}
function escapeHtml(text): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Mirrors the whitespace rules of the rich text rendering below (leading
// whitespace kept, mid-line runs collapsed, tabs as single spaces) so the
// plain and colored layouts are identical.
function plainForDisplay(code): string {
return code.split("\n").map(line => {
const i = line.search(/\S/);
if (i < 0)
return "";
const lead = line.slice(0, i).replace(/\t/g, " ");
return lead + line.slice(i).replace(/\s+/g, " ").replace(/\s+$/, "");
}).join("\n");
}
function highlightedHtml(code, spans): string {
let out;
if (!spans.length) {
out = escapeHtml(code);
} else {
out = "";
let pos = 0;
for (let i = 0; i < spans.length; i++) {
const span = spans[i];
const start = Math.min(span.start, code.length);
const end = Math.min(span.start + span.length, code.length);
if (end <= pos)
continue;
if (start > pos)
out += escapeHtml(code.slice(pos, start));
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(start, end)) + "</font>";
pos = end;
}
if (pos < code.length)
out += escapeHtml(code.slice(pos));
}
return out.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, "&nbsp;")).replace(/\n/g, "<br>");
}
implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin
implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeRect.implicitHeight + codeRect.anchors.margins + codeRect.anchors.topMargin
color: root.codeBackgroundColor
radius: Tokens.rounding.medium
onLanguageChanged: {
html = "";
colored = false;
if (root.visible)
refreshTimer.restart();
}
onCodeChanged: {
colored = false;
if (root.visible)
refreshTimer.restart();
}
onVisibleChanged: if (root.visible)
refreshTimer.restart()
Timer {
id: refreshTimer
interval: 200
repeat: false
onTriggered: refresh()
}
RowLayout {
id: headerRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Tokens.padding.small
anchors.leftMargin: Tokens.padding.small
anchors.rightMargin: Tokens.padding.small
spacing: Tokens.spacing.small
Item {
id: iconItem
readonly property string iconPath: CodeIcons.path(root.language)
Layout.fillHeight: true
implicitWidth: height
Shape {
id: shape
anchors.centerIn: parent
visible: iconItem.iconPath !== ""
preferredRendererType: Shape.CurveRenderer
scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width)
ShapePath {
strokeColor: "transparent"
fillColor: Colors.palette.m3tertiary
fillRule: ShapePath.WindingFill
PathSvg {
path: iconItem.iconPath
}
}
}
Shape {
id: fallbackShape
anchors.centerIn: parent
anchors.verticalCenterOffset: -1
visible: iconItem.iconPath === ""
preferredRendererType: Shape.CurveRenderer
scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width)
ShapePath {
strokeColor: Colors.palette.m3tertiary
strokeWidth: 16
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
fillColor: "transparent"
PathSvg {
path: "M 40 64 L 112 128 L 40 192"
}
}
ShapePath {
strokeColor: Colors.palette.m3tertiary
strokeWidth: 16
capStyle: ShapePath.RoundCap
fillColor: "transparent"
PathSvg {
path: "M 120 192 L 216 192"
}
}
}
}
CustomText {
id: langText
Layout.alignment: Qt.AlignVCenter
color: root.codeHeaderColor
font: Tokens.font.label.small
text: CodeIcons.name(root.language)
visible: root.language.length > 0
}
Item {
Layout.fillWidth: true
}
IconButton {
icon: root.copied ? "check" : "content_copy"
inactiveColor: "transparent"
inactiveOnColor: root.codeHeaderColor
label.animate: true
type: IconButton.Text
onClicked: {
Quickshell.clipboardText = root.code;
root.copied = true;
copyResetTimer.restart();
}
Timer {
id: copyResetTimer
interval: 1500
onTriggered: root.copied = false
}
}
}
CustomClippingRect {
id: codeRect
anchors.left: parent.left
anchors.top: headerRow.bottom
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.topMargin: Tokens.padding.small
radius: root.radius - anchors.margins
anchors.margins: Tokens.padding.extraSmall
implicitWidth: codeText.implicitWidth + codeFlick.anchors.margins * 2
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
color: CodeColors.active.bg
Flickable {
id: codeFlick
anchors.fill: parent
anchors.margins: Tokens.padding.small
CustomScrollBar.horizontal: CustomScrollBar {
flickable: codeFlick
parent: codeFlick.parent
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
}
TextAreaBase.flickable: TextAreaBase {
id: codeText
color: CodeColors.active.normal
layer.enabled: true
clip: false
font: Tokens.font.mono.small
textFormat: root.colored ? Text.RichText : Text.PlainText
text: root.colored ? root.html : root.plainForDisplay(root.code)
readOnly: true
}
}
}
}
+231
View File
@@ -0,0 +1,231 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
import ZShell.Config
Singleton {
id: root
readonly property JsonObject schemes: JsonObject {
readonly property Scheme oneDark: Scheme {
comment: "#5c6370"
string: "#98c379"
stringKey: "#e06c75"
number: "#d19a66"
keyword: "#c678dd"
type: "#e5c07b"
functions: "#61afef"
method: "#61afef"
macro: "#98c379"
preproc: "#abb2bf"
operator: "#abb2bf"
property: "#abb2bf"
label: "#e06c75"
attribute: "#d19a66"
bg: "#282c34"
normal: "#abb2bf"
}
readonly property Scheme nord: Scheme {
comment: "#4c566a"
string: "#a3be8c"
stringKey: "#ebcb8b"
number: "#b48ead"
keyword: "#81a1c1"
type: "#8fbcbb"
functions: "#88c0d0"
method: "#88c0d0"
macro: "#5e81ac"
preproc: "#5e81ac"
operator: "#81a1c1"
property: "#d8dee9"
label: "#d08770"
attribute: "#d8dee9"
bg: "#2e3440"
normal: "#d8dee9"
}
readonly property Scheme dracula: Scheme {
comment: "#6272a4"
string: "#f1fa8c"
stringKey: "#f8f8f2"
number: "#ffb86c"
keyword: "#ff79c6"
type: "#8be9fd"
functions: "#50fa7b"
method: "#50fa7b"
macro: "#ff79c6"
preproc: "#ff79c6"
operator: "#f8f8f2"
property: "#f8f8f2"
label: "#6272a4"
attribute: "#8be9fd"
bg: "#282a36"
normal: "#f8f8f2"
}
readonly property Scheme githubDark: Scheme {
comment: "#8b949e"
string: "#a5d6ff"
stringKey: "#79c0ff"
number: "#79c0ff"
keyword: "#ff7b72"
type: "#ffa657"
functions: "#d2a8ff"
method: "#d2a8ff"
macro: "#ff7b72"
preproc: "#79c0ff"
operator: "#ff7b72"
property: "#79c0ff"
label: "#7ee787"
attribute: "#7ee787"
bg: "#0d1117"
normal: "#c9d1d9"
}
readonly property Scheme solarizedDark: Scheme {
comment: "#586e75"
string: "#2aa198"
stringKey: "#268bd2"
number: "#2aa198"
keyword: "#859900"
type: "#b58900"
functions: "#268bd2"
method: "#268bd2"
macro: "#cb4b16"
preproc: "#cb4b16"
operator: "#859900"
property: "#268bd2"
label: "#6c71c4"
attribute: "#657b83"
bg: "#002b36"
normal: "#839496"
}
readonly property Scheme monokai: Scheme {
comment: "#75715e"
string: "#e6db74"
stringKey: "#f8f8f2"
number: "#ae81ff"
keyword: "#f92672"
type: "#a6e22e"
functions: "#a6e22e"
method: "#a6e22e"
macro: "#a6e22e"
preproc: "#f92672"
operator: "#f92672"
property: "#fda5ff"
label: "#f92672"
attribute: "#a6e22e"
bg: "#272822"
normal: "#f8f8f2"
}
readonly property Scheme gruvboxDark: Scheme {
comment: "#928374"
string: "#b8bb26"
stringKey: "#ebdbb2"
number: "#d3869b"
keyword: "#fb4934"
type: "#fabd2f"
functions: "#b8bb26"
method: "#b8bb26"
macro: "#8ec07c"
preproc: "#8ec07c"
operator: "#ebdbb2"
property: "#83a598"
label: "#fb4934"
attribute: "#8ec07c"
bg: "#1d2021"
normal: "#ebdbb2"
}
readonly property Scheme catppuccinMocha: Scheme {
comment: "#9399b2"
string: "#a6e3a1"
stringKey: "#b4befe"
number: "#fab387"
keyword: "#cba6f7"
type: "#f9e2af"
functions: "#89b4fa"
method: "#89b4fa"
macro: "#cba6f7"
preproc: "#f5c2e7"
operator: "#89dceb"
property: "#b4befe"
label: "#74c7ec"
attribute: "#f9e2af"
bg: "#1e1e2e"
normal: "#cdd6f4"
}
readonly property Scheme tokyoNight: Scheme {
comment: "#565f89"
string: "#9ece6a"
stringKey: "#73daca"
number: "#ff9e64"
keyword: "#9d7cd8"
type: "#2ac3de"
functions: "#7aa2f7"
method: "#7aa2f7"
macro: "#7dcfff"
preproc: "#7dcfff"
operator: "#89ddff"
property: "#73daca"
label: "#7aa2f7"
attribute: "#73daca"
bg: "#1a1b26"
normal: "#c0caf5"
}
readonly property Scheme ayuDark: Scheme {
comment: "#5a6673"
string: "#aad94c"
stringKey: "#aad94c"
number: "#d2a6ff"
keyword: "#ff8f40"
type: "#59c2ff"
functions: "#ffb454"
method: "#ffb454"
macro: "#59c2ff"
preproc: "#ff8f40"
operator: "#f29668"
property: "#f07178"
label: "#59c2ff"
attribute: "#ffb454"
bg: "#0a0e14"
normal: "#bfbdb6"
}
readonly property Scheme palenight: Scheme {
comment: "#697098"
string: "#c3e88d"
stringKey: "#82b1ff"
number: "#f78c6c"
keyword: "#ff5370"
type: "#ffcb6b"
functions: "#82b1ff"
method: "#82b1ff"
macro: "#c792ea"
preproc: "#ffcb6b"
operator: "#89ddff"
property: "#c3e88d"
label: "#c792ea"
attribute: "#ffcb6b"
bg: "#292d3e"
normal: "#bfc7d5"
}
}
readonly property var active: schemes[Config.llm.appearance.scheme]
component Scheme: JsonObject {
property color comment: "#697098"
property color string: "#c3e88d"
property color stringKey: "#82b1ff"
property color number: "#f78c6c"
property color keyword: "#ff5370"
property color type: "#ffcb6b"
property color functions: "#82b1ff"
property color method: "#82b1ff"
property color macro: "#c792ea"
property color preproc: "#ffcb6b"
property color operator: "#89ddff"
property color property: "#c3e88d"
property color label: "#c792ea"
property color attribute: "#ffcb6b"
property color bg: "#292d3e"
property color normal: "#bfc7d5"
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,165 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Llm
import ZShell.Config
import qs.Modules.Sidebar.Chat
import qs.Components
import qs.Services
Item {
id: root
required property bool isUser
required property string text
required property var blocks
required property bool running
required property bool hovered
required property Repeater repeater
required property int index
required property ChatMessage message
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
signal edit(text: string)
implicitHeight: bubble.implicitHeight + (actionsRow.shouldBeActive ? actionsRow.implicitHeight + actionsRow.anchors.topMargin : 0)
CustomRect {
id: bubble
property real liveTargetWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
property real frozenWidth: 0.0
property bool layoutFrozen: false
property real layoutWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
radius: Tokens.rounding.medium
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
implicitWidth: layoutFrozen ? frozenWidth : layoutWidth
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : markdown.implicitHeight + markdown.anchors.topMargin * 2
anchors.right: root.isUser ? parent.right : undefined
clip: true
transform: Scale {
id: stretchScale
origin.x: root.isUser ? bubble.width : 0
xScale: bubble.layoutFrozen && bubble.frozenWidth > 0 ? bubble.liveTargetWidth / bubble.frozenWidth : 1.0
}
states: [
State {
name: "toSlim"
when: ChatState.isWindow && !ChatState.isWide && !ChatState.settledSlim
PropertyChanges {
bubble.layer.enabled: true
bubble.layoutFrozen: true
}
},
State {
name: "toWide"
when: ChatState.isWindow && ChatState.isWide && !ChatState.settledWide
PropertyChanges {
bubble.layer.enabled: true
bubble.layoutFrozen: true
}
}
]
onLayoutFrozenChanged: {
if (layoutFrozen)
frozenWidth = width;
}
TextEditBase {
id: msgText
property string cachedText: root.text
property bool cancelled: false
visible: root.isUser
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
animateCursor: false
color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary
font: Tokens.font.body.small
readOnly: true
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
text: root.isUser ? root.text : ""
textFormat: CustomText.MarkdownText
wrapMode: Text.WordWrap
Keys.onPressed: event => {
if (event.key == Qt.Key_Return) {
if (!(event.modifiers & Qt.ShiftModifier)) {
readOnly = true;
event.accepted = true;
}
} else if (event.key == Qt.Key_Escape) {
cancelled = true;
readOnly = true;
event.accepted = true;
}
}
onEditingFinished: {
readOnly = true;
}
onReadOnlyChanged: {
if (readOnly) {
animateCursor = false;
textFormat = CustomText.MarkdownText;
if (cancelled) {
text = cachedText;
cancelled = false;
return;
}
root.edit(text);
} else {
textFormat = CustomText.PlainText;
text = cachedText;
forceActiveFocus();
cursorPosition = text.length;
animateCursor = true;
}
}
}
MarkdownBlocks {
id: markdown
visible: !root.isUser
anchors.topMargin: Tokens.padding.medium
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
blocks: root.blocks
}
}
Actions {
id: actionsRow
readonly property bool shouldBeActive: !root.running && root.repeater.count === (root.index + 1)
anchors.left: parent.left
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
anchors.top: bubble.bottom
opacity: shouldBeActive ? 1 : 0
visible: opacity > 0
anchors.topMargin: Tokens.spacing.medium
edit: msgText
hovered: root.hovered
isUser: root.isUser
running: root.running
message: root.message
Behavior on opacity {
Anim {}
}
}
}
@@ -0,0 +1,32 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import qs.Components
import qs.Services
ColumnLayout {
id: root
spacing: Tokens.spacing.small
Item {
Layout.fillHeight: true
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant
fontStyle: Tokens.font.icon.extraLarge
text: "chat"
}
CustomText {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("Send a message to get started")
}
Item {
Layout.fillHeight: true
}
}
@@ -0,0 +1,128 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Llm
import ZShell.Config
import qs.Components
import qs.Services
Item {
id: root
required property ChatGeneration current
signal dismiss
implicitHeight: 0
Component.onCompleted: {
root.implicitHeight = Qt.binding(() => errorInner.implicitHeight + errorInner.anchors.margins * 2);
enterAnim.start();
}
Behavior on implicitHeight {
Anim {}
}
ParallelAnimation {
id: enterAnim
Anim {
target: container
property: "y"
from: container.implicitHeight
to: 0
}
Anim {
target: container
property: "opacity"
from: 0
to: 1
}
}
ParallelAnimation {
id: exitAnim
Anim {
target: root
property: "implicitHeight"
from: root.implicitHeight
to: 0
}
Anim {
target: container
property: "y"
to: container.implicitHeight
from: 0
}
Anim {
target: container
property: "opacity"
to: 0
from: 1
}
onFinished: root.dismiss()
}
CustomRect {
id: container
anchors.left: parent.left
anchors.right: parent.right
radius: Tokens.rounding.medium
color: Colors.palette.m3errorContainer
implicitHeight: errorInner.implicitHeight + errorInner.anchors.margins * 2
ColumnLayout {
id: errorInner
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
spacing: Tokens.spacing.small
CustomText {
id: header
Layout.fillWidth: true
font: Tokens.font.headline.small
elide: CustomText.ElideRight
color: Colors.palette.m3onErrorContainer
text: qsTr("An error occured")
}
CustomText {
id: body
Layout.fillWidth: true
wrapMode: CustomText.WordWrap
color: Colors.palette.m3onErrorContainer
text: root.current.errorMessage || LlmClient.serverError
}
}
IconButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
isRound: true
icon: "close"
type: IconButton.Text
font: Tokens.font.icon.medium
onClicked: exitAnim.start()
}
}
}
@@ -0,0 +1,90 @@
import QtQuick
import Quickshell
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
Column {
id: root
required property var blocks
spacing: Tokens.spacing.small
Repeater {
id: blockRep
model: ScriptModel {
values: root.blocks
objectProp: "id"
}
delegate: DelegateChooser {
role: "type"
DelegateChoice {
roleValue: MarkdownBlock.Type.Code
delegate: CodeBlockView {
required property MarkdownBlock modelData
language: modelData.language
code: modelData.code
anchors.right: parent.right
anchors.margins: Tokens.spacing.small
anchors.left: parent.left
}
}
DelegateChoice {
roleValue: MarkdownBlock.Type.Math
delegate: MathBlockView {
required property MarkdownBlock modelData
latex: modelData.latex
anchors.right: parent.right
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
}
}
DelegateChoice {
roleValue: MarkdownBlock.Type.Heading
delegate: TextEditBase {
required property MarkdownBlock modelData
color: Colors.palette.m3onSurface
anchors.right: parent.right
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
text: modelData.text
textFormat: Text.MarkdownText
readOnly: true
font: {
if (modelData.level <= 1)
return Tokens.font.title.builders.large.weight(Font.Bold).build();
if (modelData.level === 2)
return Tokens.font.title.builders.medium.weight(Font.Bold).build();
return Tokens.font.title.builders.small.weight(Font.Bold).build();
}
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
}
DelegateChoice {
roleValue: MarkdownBlock.Type.Text
delegate: BubbleEdit {
required property MarkdownBlock modelData
anchors.right: parent.right
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
text: modelData.text
}
}
}
}
}
@@ -0,0 +1,47 @@
import QtQuick
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
Item {
id: root
required property string latex
property color mathColor: Colors.palette.m3onSurface
property font font: Tokens.font.headline.medium
implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2
LlmMathText {
id: math
latex: root.latex
color: root.mathColor
fontPointSize: root.font.pointSize
devicePixelRatio: Screen.devicePixelRatio
}
Image {
id: equationImage
anchors.centerIn: parent
source: math.imageId.length ? "image://llmmath/" + math.imageId : ""
visible: math.ok
fillMode: Image.PreserveAspectFit
smooth: true
width: Math.min(math.width, root.width)
height: math.width > 0 ? width * (math.height / math.width) : 0
}
CustomText {
id: fallbackText
anchors.centerIn: parent
color: root.mathColor
font: Tokens.font.mono.medium
text: root.latex
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
visible: !math.ok
}
}
@@ -0,0 +1,364 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import ZShell.Components
import ZShell.Config
import ZShell.Llm
import qs.Modules.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: root.isUser ? [
{
kind: "content",
id: 0,
text: root.modelData.content,
markdown: [],
running: false
}
] : 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,
text: seg.text,
markdown: seg.markdown,
running: seg.running
});
} 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
CustomRect {
anchors.fill: parent
anchors.margins: Tokens.spacing.extraSmall
radius: Tokens.rounding.medium
color: Colors.tPalette.m3surfaceContainer
clip: true
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
fontStyle: Tokens.font.icon.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
text: modelData.text
blocks: modelData.markdown
running: modelData.running
width: root.width
repeater: segmentRep
message: root.modelData
hovered: root.containsMouse
onEdit: text => {
root.modelData.edit(text);
if (root.isUser)
root.modelData.generate();
}
}
}
}
}
}
}
@@ -0,0 +1,351 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import ZShell.Components
import ZShell.Llm
import ZShell.Blobs
import qs.Modules.Sidebar.Chat
import qs.Components
import qs.Services
Item {
id: root
required property Item rootParent
required property Item fabRoot
property bool open: false
property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2
property int openHeight: Math.min(modelsContent.item?.implicitHeight, maxHeight)
property int openWidth: root.rootParent.width - Tokens.padding.medium * 2
property real openHeightProg: openHeight / maxHeight
property int collapsedY: 0
property int horizontalContentMargin
property string header: qsTr("Available models")
property string acceptLabel: qsTr("Accept")
z: open || dialogTransition.running ? 2 : 0
Layout.fillWidth: true
implicitHeight: fabRoot.implicitHeight
function prettyModelName(): string {
if (modelsContent.item?.modelsList.count < 1)
return qsTr("No models found");
const pathList = LlmClient.model.split("\/");
const name = pathList[pathList.length - 1];
if (!name)
return qsTr("No model selected");
return name;
}
Behavior on implicitHeight {
Anim {}
}
function reparentWrapper(): void {
const newParent = open ? root.rootParent : root;
const c = modelsWrapper.parent.mapToItem(newParent, modelsWrapper.x + modelsWrapper.width / 2, modelsWrapper.y + modelsWrapper.height / 2);
root.collapsedY = modelsWrapper.y;
modelsWrapper.parent = newParent;
modelsWrapper.x = c.x - modelsWrapper.width / 2;
modelsWrapper.y = c.y - modelsWrapper.height / 2;
}
BlobGroup {
id: blobGroup
color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer
Behavior on color {
CAnim {}
}
}
MouseArea {
id: backdrop
anchors.fill: parent
enabled: false
hoverEnabled: enabled
preventStealing: true
parent: root.open ? root.rootParent : root
onClicked: root.open = false
}
Item {
id: modelsWrapper
width: root.width
height: modelBox.implicitHeight
z: 1
states: State {
name: "open"
when: root.open
PropertyChanges {
backdrop.enabled: true
modelsBackground.radius: Tokens.rounding.largeIncreased
modelsContent.opacity: 1
modelsWrapper.height: root.openHeight
modelsWrapper.width: root.openWidth
modelsWrapper.x: (root.rootParent.width - root.openWidth) / 2
modelsWrapper.y: root.rootParent.height - root.openHeight - Tokens.spacing.medium - Tokens.padding.medium - root.fabRoot.implicitHeight
elevation.opacity: 1
modelBox.opacity: 0
}
}
transitions: Transition {
id: dialogTransition
SequentialAnimation {
ScriptAction {
script: root.reparentWrapper()
}
Anim {
properties: "x,y"
}
}
PropertyAction {
property: "enabled"
}
Anim {
properties: "opacity,radius"
type: Anim.DefaultEffects
}
Anim {
properties: "width,height"
}
}
Elevation {
id: elevation
anchors.fill: parent
level: 4
opacity: 0
radius: modelsBackground.radius
transform: Matrix4x4 {
matrix: modelsBackground.deformMatrix
}
}
BlobRect {
id: modelsBackground
anchors.fill: parent
radius: modelLayer.pressed ? Tokens.rounding.small : Tokens.rounding.extraLarge
deformScale: 0
group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
Behavior on radius {
Anim {
type: Anim.DefaultEffects
}
}
}
CustomRect {
id: modelBox
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
implicitHeight: root.fabRoot.implicitHeight
radius: modelsBackground.radius
CustomText {
id: label
text: root.prettyModelName()
color: Colors.palette.m3onSurfaceVariant
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.large
anchors.verticalCenter: parent.verticalCenter
}
MaterialIcon {
color: Colors.palette.m3onSurfaceVariant
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.large
anchors.verticalCenter: parent.verticalCenter
text: "unfold_more"
fontStyle: Tokens.font.icon.large
}
StateLayer {
id: modelLayer
onClicked: {
ChatState.fabExpanded = false;
root.open = true;
}
}
}
Loader {
id: modelsContent
active: opacity > 0
anchors.fill: parent
asynchronous: false
opacity: 0
sourceComponent: MouseArea {
readonly property alias modelsList: modelsList
onWheel: event => event.accepted = true
preventStealing: true
clip: true
implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins * 2
ColumnLayout {
id: innerLayout
anchors.bottomMargin: Tokens.padding.largeIncreased
anchors.fill: parent
anchors.margins: Tokens.padding.extraLarge
spacing: 0
CustomText {
font: Tokens.font.title.builders.large.weight(Font.Normal).build()
text: root.header
}
VerticalFadeListView {
id: modelsList
Layout.fillWidth: true
Layout.leftMargin: root.horizontalContentMargin
Layout.topMargin: Tokens.spacing.medium
Layout.bottomMargin: Tokens.spacing.medium
Layout.rightMargin: root.horizontalContentMargin
Layout.preferredHeight: 400
Layout.maximumHeight: 400
model: LlmClient.availableModels
currentIndex: LlmClient.availableModels.indexOf(LlmClient.model)
visible: opacity > 0
Behavior on opacity {
Anim {}
}
CustomScrollBar.vertical: CustomScrollBar {
flickable: modelsList
}
delegate: CustomRect {
id: model
required property var modelData
required property int index
readonly property bool selected: ListView.view.currentIndex === model.index
implicitWidth: ListView.view.width
implicitHeight: name.implicitHeight + Tokens.padding.small * 2
radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium
color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0)
Behavior on radius {
Anim {
type: Anim.SlowEffects
}
}
RowLayout {
id: layout
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Tokens.padding.small
anchors.leftMargin: Tokens.padding.large
anchors.rightMargin: Tokens.padding.large
CustomText {
id: name
text: model.modelData
elide: CustomText.ElideRight
color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
Layout.fillWidth: true
}
MaterialIcon {
id: check
color: Colors.palette.m3onTertiaryContainer
fontStyle: Tokens.font.icon.medium
opacity: model.selected ? 1 : 0
visible: opacity > 0
text: "check"
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
}
}
StateLayer {
id: stateLayer
onClicked: {
model.ListView.view.currentIndex = model.index;
}
}
}
}
RowLayout {
Layout.alignment: Qt.AlignRight
spacing: Tokens.spacing.extraSmall
TextButton {
horizontalPadding: Tokens.padding.largeIncreased
isRound: true
text: qsTr("Cancel")
type: TextButton.Text
verticalPadding: Tokens.padding.small
stateLayer.preventStealing: true
onClicked: {
root.open = false;
}
}
TextButton {
horizontalPadding: Tokens.padding.largeIncreased
isRound: true
text: root.acceptLabel
type: TextButton.Text
stateLayer.preventStealing: true
verticalPadding: Tokens.padding.small
onClicked: {
LlmClient.selectModel(modelsList.currentItem.modelData);
root.open = false;
}
}
}
}
}
}
}
}
@@ -0,0 +1,240 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Blobs
import ZShell.Config
import ZShell.Components
import ZShell.Llm
import qs.Components
import qs.Services
Item {
id: root
required property int index
required property var modelData
required property var blocks
readonly property var segments: modelData.segments
required property Item rootParent
required property ChatGeneration current
readonly property bool isActive: index === blocks.length - 1
property bool expanded: false
readonly property var lastSegment: root.segments[root.segments.length - 1]
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
readonly property bool pendingApproval: current.toolApprovalPending && segments.some(s => s.type === LlmSegment.Type.ToolCall && s.status === LlmToolCall.Status.Pending)
implicitHeight: {
const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin;
const collapsed = layout.implicitHeight;
const approval = layout.implicitHeight + approvalPrompt.implicitHeight + approvalPrompt.anchors.topMargin;
if (expanded)
return expand;
else if (pendingApproval)
return approval;
else
return collapsed;
}
onCurrentChanged: expanded = false
Behavior on implicitHeight {
Anim {
type: Anim.DefaultEffects
}
}
RowLayout {
id: layout
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
Item {
implicitHeight: expandBtn.implicitHeight
implicitWidth: expandBtn.implicitWidth
LoadingIndicator {
id: spinnerReasoning
implicitSize: collapsedText.implicitHeight
anchors.centerIn: parent
opacity: root.isActive ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {}
}
}
IconButton {
id: expandBtn
font: Tokens.font.icon.medium
icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
anchors.centerIn: parent
opacity: root.isActive ? 0 : 1
enabled: opacity > 0
rotation: root.expanded ? 180 : 0
type: IconButton.Text
Behavior on rotation {
Anim {}
}
onClicked: root.expanded = !root.expanded
}
}
CustomText {
id: collapsedText
color: Colors.palette.m3outline
font: Tokens.font.label.small
Layout.fillWidth: true
Layout.leftMargin: Tokens.spacing.medium
text: {
if (root.isActive) {
if (root.pendingApproval)
return qsTr("Waiting for approval...");
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
return qsTr("Using %1...").arg(root.lastSegment.name);
return qsTr("Thinking...");
}
return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1));
}
Behavior on opacity {
Anim {}
}
}
}
ApprovalPrompt {
id: approvalPrompt
anchors.top: layout.bottom
anchors.topMargin: Tokens.spacing.small
anchors.left: parent.left
anchors.right: parent.right
visible: root.pendingApproval
rootParent: root.rootParent
current: root.current
}
CustomClippingRect {
id: expandedRect
anchors.left: parent.left
anchors.right: parent.right
anchors.top: layout.bottom
anchors.bottom: parent.bottom
anchors.topMargin: Tokens.spacing.small
color: Colors.palette.m3surfaceContainerLow
implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2
opacity: root.expanded ? 1 : 0
radius: Tokens.rounding.medium
visible: opacity > 0
Behavior on opacity {
Anim {}
}
ListView {
id: expandedContent
anchors.fill: parent
anchors.margins: Tokens.padding.medium
model: root.segments
spacing: Tokens.spacing.medium
delegate: Item {
id: segment
required property var modelData
required property int index
implicitWidth: ListView.view.width
implicitHeight: header.implicitHeight + content.implicitHeight + content.anchors.topMargin
RowLayout {
id: header
anchors.left: parent.left
anchors.right: parent.right
spacing: Tokens.spacing.small
MaterialIcon {
text: segment.modelData.type === LlmSegment.Type.Reasoning ? "cognition" : "build"
fill: 1
}
CustomText {
Layout.fillWidth: true
text: segment.modelData.type === LlmSegment.Type.Reasoning ? qsTr("Thought for %1s").arg((segment.modelData.elapsedMs / 1000).toFixed(1)) : qsTr("Used %1 for %2s").arg(segment.modelData.name).arg((segment.modelData.elapsedMs / 1000).toFixed(1))
}
}
CustomText {
id: content
function toolTarget(seg: var): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
} catch (e) {}
if (a && typeof a === "object") {
const v = a.url ?? a.path;
if (v !== undefined && v !== null)
return String(v);
}
return String(seg.arguments);
}
function prettyTool(seg: var): string {
if (seg.name === "webfetch") {
switch (seg.status) {
case LlmToolCall.Status.Running:
return qsTr("Fetching");
case LlmToolCall.Status.Pending:
return qsTr("Pending fetch");
case LlmToolCall.Status.Success:
return qsTr("Fetched");
case LlmToolCall.Status.Error:
return qsTr("Failed to fetch");
default:
return qsTr("Fetched website");
}
} else if (seg.name === "readfile") {
switch (seg.status) {
case LlmToolCall.Status.Running:
return qsTr("Reading");
case LlmToolCall.Status.Pending:
return qsTr("Pending read");
case LlmToolCall.Status.Success:
return qsTr("Read");
case LlmToolCall.Status.Error:
return qsTr("Failed to read");
default:
return qsTr("Read file");
}
}
}
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.small
anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.small
anchors.right: parent.right
color: Colors.palette.m3outline
font: Tokens.font.body.small
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("%1 %2").arg(content.prettyTool(segment.modelData)).arg(content.toolTarget(segment.modelData))
}
}
}
}
}
+53
View File
@@ -0,0 +1,53 @@
pragma Singleton
import Quickshell
import QtQuick
import ZShell.Config
import qs.Helpers
import qs.Services
Singleton {
id: root
function create(parent: Item, props: var): void {
chatComp.createObject(parent ?? dummy, props);
ShellState.forActive().sidebar = false;
ChatState.isWindow = true;
}
QtObject {
id: dummy
}
Component {
id: chatComp
FloatingWindow {
id: win
property var props
color: Colors.tPalette.m3surface
implicitHeight: chat.implicitHeight
implicitWidth: chat.implicitWidth
minimumSize.height: Config.sidebar.sizes.width
minimumSize.width: Config.sidebar.sizes.width
surfaceFormat.opaque: false
title: qsTr("ZShell - %1").arg(ChatState.title)
Component.onCompleted: ChatState.screen = screen
onVisibleChanged: {
if (!visible) {
destroy();
ChatState.isWindow = false;
}
}
SidebarView {
id: chat
anchors.fill: parent
}
}
}
}
+207
View File
@@ -0,0 +1,207 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import ZShell.Blobs
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Modules.Sidebar.Chat.Content
import qs.Services
Item {
id: root
property int breakpoint: Math.round(Config.sidebar.sizes.width * 2) + Tokens.spacing.medium * 2 + Tokens.padding.medium * 3
property color blobColor: Colors.tPalette.m3surfaceContainerLow
readonly property int iconSize: Math.round(Tokens.font.icon.extraLarge.pointSize * (96 / 72)) + Tokens.padding.large * 2
property alias conversationModel: sidebar.model
property bool chatOpen: ChatState.chatSession !== null
readonly property bool isWide: root.width >= root.breakpoint
readonly property bool showList: isWide || !chatOpen
readonly property bool showContent: !isWide && chatOpen
function openConversation(conv: ChatSession, index: int): void {
}
BlobGroup {
id: blobGroup
color: root.blobColor
smoothing: Tokens.rounding.medium
}
BlobInvertedRect {
id: invertedRect
anchors.fill: parent
borderBottom: Tokens.padding.small
borderLeft: sidebar.implicitWidth + sidebar.anchors.margins + Tokens.spacing.medium
borderRight: Tokens.padding.small
borderTop: Tokens.padding.small
group: blobGroup
opacity: root.blobColor.a
radius: Tokens.rounding.largeIncreased
}
ChatList {
id: sidebar
anchors.top: parent.top
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
implicitWidth: root.isWide || ChatState.narrowSidebarExpanded ? Config.sidebar.sizes.width : root.iconSize
highlight: true
slim: !root.isWide && !ChatState.narrowSidebarExpanded
z: 1
Behavior on implicitWidth {
Anim {
type: Anim.DefaultEffects
}
}
model: LlmClient.chats
onLoadChatRequest: (chat, index) => {
ChatState.currentIdx = index;
ChatState.chatSession = chat;
}
onDeleteChatRequest: chat => {
const wasCurrent = chat === ChatState.chatSession;
LlmClient.chats.remove(chat);
if (wasCurrent)
ChatState.openDraft();
}
onNewChatRequest: ChatState.openDraft()
onRequestExpand: {
ChatState.narrowSidebarExpanded = true;
}
}
CustomClippingRect {
anchors.fill: parent
anchors.leftMargin: invertedRect.borderLeft
anchors.topMargin: invertedRect.borderTop
anchors.bottomMargin: invertedRect.borderBottom
anchors.rightMargin: invertedRect.borderRight
radius: invertedRect.radius
children: [contentArea, dim]
}
CustomRect {
id: dim
anchors.fill: parent
color: Colors.palette.m3shadow
opacity: ChatState.narrowSidebarExpanded && !root.isWide ? 0.3 : 0
z: 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
Item {
id: contentArea
property int leftMargin: sidebar.implicitWidth
z: 0
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: parent.right
implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2
states: State {
name: "wide"
when: !root.isWide
PropertyChanges {
contentArea.leftMargin: root.iconSize
}
}
transitions: Transition {
to: "wide"
Anim {
type: Anim.DefaultEffects
property: "leftMargin"
}
}
ChatHost {
id: convHost
anchors.fill: parent
clip: true
onLoadingChanged: if (!loading)
root.chatOpen = true
}
MouseArea {
id: dimArea
anchors.fill: parent
preventStealing: true
enabled: !root.isWide && ChatState.narrowSidebarExpanded
cursorShape: undefined
onClicked: {
const x = mouseX;
const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2;
if (x > invertedRect.borderLeft - margin) {
ChatState.narrowSidebarExpanded = false;
ChatState.fabExpanded = false;
}
}
HoverHandler {
cursorShape: dimArea.enabled ? Qt.ArrowCursor : undefined
enabled: dimArea.enabled
}
}
}
Binding {
target: ChatState
property: "windowIsWide"
value: root.showList
}
Binding {
target: ChatState
property: "isWide"
value: root.isWide
}
Binding {
target: ChatState
value: contentArea.leftMargin === root.iconSize
property: "settledSlim"
}
Binding {
target: ChatState
value: contentArea.leftMargin === sidebar.implicitWidth
property: "settledWide"
}
Component {
id: conversationView
ChatContent {}
}
}