import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 Item { id: chatPane property bool sidebarVisible: true signal toggleSidebar() ColumnLayout { anchors.fill: parent spacing: 0 // Header - shows active session/agent and state RowLayout { Layout.fillWidth: true Layout.margins: 6 spacing: 8 // Sidebar toggle button ToolButton { text: chatPane.sidebarVisible ? "◀" : "▶" font.pixelSize: 12 implicitWidth: 24 implicitHeight: 24 onClicked: chatPane.toggleSidebar() ToolTip.text: chatPane.sidebarVisible ? "Hide sidebar (Ctrl+B)" : "Show sidebar (Ctrl+B)" ToolTip.visible: hovered } // Active session/agent label Label { text: { var sessionName = "" var agentName = "" for (var i = 0; i < sessionModel.count; ++i) { var item = sessionModel.get(i) if (item.type === "session" && item.id === ollie.activeSessionId) { sessionName = item.displayName || item.id } if (item.type === "agent" && item.agentId === ollie.activeAgentId && item.parentId === ollie.activeSessionId) { agentName = item.displayName || item.agentId } } if (sessionName && agentName) { return sessionName + " / " + agentName } else if (sessionName) { return sessionName + " (no agent)" } return "(no session)" } font.bold: true font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize elide: Text.ElideMiddle Layout.fillWidth: true } // State indicator Rectangle { width: 10; height: 10; radius: 5 color: { if (!ollie.activeAgentId) return "#9e9e9e" // gray when no agent if (ollie.activeSessionId && ollie.isSessionPaused(ollie.activeSessionId)) return "#9e9e9e" // gray when paused if (ollie.activeState === "idle") return "#4caf50" if (ollie.activeState.startsWith("calling")) return "#ff9800" return "#2196f3" } } Label { text: { if (!ollie.activeAgentId) return "" if (ollie.activeSessionId && ollie.isSessionPaused(ollie.activeSessionId)) return "inactive" return ollie.activeState } opacity: 0.7 font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11 } } Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor } // Chat display — block-based ListView with scroll-to-bottom button Item { Layout.fillWidth: true Layout.fillHeight: true clip: true // Placeholder when no agent selected or disconnected Label { anchors.centerIn: parent text: { if (!ollie.daemonConnected) return "Not connected" if (!ollie.activeSessionId) return "Select a session" return "Select an agent to view chat" } color: theme.midColor font.italic: true font.pixelSize: theme.fontSize > 0 ? theme.fontSize : 12 visible: !ollie.activeAgentId || !ollie.daemonConnected } ListView { id: chatView model: chatModel spacing: 4 anchors.fill: parent anchors.margins: 4 boundsBehavior: Flickable.StopAtBounds visible: ollie.daemonConnected && ollie.activeAgentId !== "" property bool followTail: true reuseItems: true onCountChanged: { if (followTail) scrollTimer.restart() } onContentHeightChanged: { if (followTail) scrollTimer.restart() } onMovementEnded: followTail = atYEnd Timer { id: scrollTimer interval: 16 onTriggered: { if (chatView.followTail && chatView.contentHeight > chatView.height) chatView.positionViewAtEnd() } } delegate: ChatBlockDelegate { width: chatView.width - 8 parentListView: chatView } } // Scroll to bottom button Button { anchors.right: parent.right anchors.bottom: parent.bottom anchors.margins: 12 text: "↓" font.pixelSize: 16 implicitWidth: 36 implicitHeight: 36 visible: chatView.visible && !chatView.followTail onClicked: { chatView.followTail = true chatView.positionViewAtEnd() } ToolTip.text: "Scroll to bottom" ToolTip.visible: hovered } } Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor } // Input RowLayout { Layout.fillWidth: true Layout.margins: 6 spacing: 4 // TextArea in ScrollView for overflow, with context menu overlay Item { Layout.fillWidth: true Layout.maximumHeight: 200 Layout.preferredHeight: Math.min(promptInput.contentHeight + 16, 200) clip: true ScrollView { id: promptScroll anchors.fill: parent ScrollBar.horizontal.policy: ScrollBar.AlwaysOff TextArea { id: promptInput width: promptScroll.width placeholderText: { if (!ollie.daemonConnected) return "Not connected" if (!ollie.activeSessionId) return "No session" if (!ollie.activeAgentId) return "No agent" if (ollie.isSessionPaused(ollie.activeSessionId)) return "Session paused" return "Message..." } enabled: ollie.daemonConnected && ollie.activeSessionId !== "" && ollie.activeAgentId !== "" && !ollie.isSessionPaused(ollie.activeSessionId) wrapMode: TextEdit.Wrap selectByMouse: true persistentSelection: true property string _lastText: "" property int _lastCursorPos: 0 onTextChanged: { _lastText = text _lastCursorPos = cursorPosition } Keys.onReturnPressed: function(event) { if (event.modifiers & Qt.ShiftModifier) { event.accepted = false } else { if (text.trim() !== "") { ollie.submit(text.trim()) text = "" } event.accepted = true } } } } // Context menu overlay - only catches right-click, on top of ScrollView MouseArea { anchors.fill: parent acceptedButtons: Qt.RightButton cursorShape: Qt.IBeamCursor onClicked: promptContextMenu.popup() } Menu { id: promptContextMenu MenuItem { text: "Cut" enabled: promptInput.selectedText.length > 0 onTriggered: promptInput.cut() } MenuItem { text: "Copy" enabled: promptInput.selectedText.length > 0 onTriggered: promptInput.copy() } MenuItem { text: "Paste" enabled: promptInput.canPaste onTriggered: promptInput.paste() } MenuSeparator {} MenuItem { text: "Select All" onTriggered: promptInput.selectAll() } } } } } Connections { target: ollie function loadChat() { chatModel.reset() if (ollie.activeSessionId && ollie.activeAgentId) { var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId) if (log) { chatModel.appendData(log); chatModel.trimFront(20) } } } function onActiveSessionIdChanged() { loadChat() } function onActiveAgentIdChanged() { loadChat() } } }