Refactor GUI into session tree and chat pane components
This commit is contained in:
parent
0e163f6ee6
commit
541ef48bb7
|
|
@ -110,11 +110,14 @@ if(OLLIE_KF5)
|
||||||
configure_file(gui/NewSessionDialog.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/NewSessionDialog.qml COPYONLY)
|
configure_file(gui/NewSessionDialog.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/NewSessionDialog.qml COPYONLY)
|
||||||
configure_file(gui/NewAgentDialog.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/NewAgentDialog.qml COPYONLY)
|
configure_file(gui/NewAgentDialog.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/NewAgentDialog.qml COPYONLY)
|
||||||
configure_file(gui/ChatBlockDelegate.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/ChatBlockDelegate.qml COPYONLY)
|
configure_file(gui/ChatBlockDelegate.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/ChatBlockDelegate.qml COPYONLY)
|
||||||
|
configure_file(gui/SessionTree.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/SessionTree.qml COPYONLY)
|
||||||
|
configure_file(gui/ChatPane.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/ChatPane.qml COPYONLY)
|
||||||
configure_file(gui/qml.qrc ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/qml.qrc COPYONLY)
|
configure_file(gui/qml.qrc ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/qml.qrc COPYONLY)
|
||||||
qt5_add_resources(GUI_KF5_QRC ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/qml.qrc)
|
qt5_add_resources(GUI_KF5_QRC ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/qml.qrc)
|
||||||
add_executable(ollie-gui
|
add_executable(ollie-gui
|
||||||
gui/main.cpp
|
gui/main.cpp
|
||||||
gui/ollie9pclient.cpp
|
gui/ollie9pclient.cpp
|
||||||
|
gui/sessionmodel.cpp
|
||||||
gui/lib9pclient.cpp
|
gui/lib9pclient.cpp
|
||||||
gui/nativestreamer.cpp
|
gui/nativestreamer.cpp
|
||||||
gui/chatblockmodel.cpp
|
gui/chatblockmodel.cpp
|
||||||
|
|
@ -203,6 +206,7 @@ else()
|
||||||
add_executable(ollie-gui
|
add_executable(ollie-gui
|
||||||
gui/main.cpp
|
gui/main.cpp
|
||||||
gui/ollie9pclient.cpp
|
gui/ollie9pclient.cpp
|
||||||
|
gui/sessionmodel.cpp
|
||||||
gui/lib9pclient.cpp
|
gui/lib9pclient.cpp
|
||||||
gui/nativestreamer.cpp
|
gui/nativestreamer.cpp
|
||||||
gui/chatblockmodel.cpp
|
gui/chatblockmodel.cpp
|
||||||
|
|
@ -232,6 +236,8 @@ else()
|
||||||
gui/NewSessionDialog.qml
|
gui/NewSessionDialog.qml
|
||||||
gui/NewAgentDialog.qml
|
gui/NewAgentDialog.qml
|
||||||
gui/ChatBlockDelegate.qml
|
gui/ChatBlockDelegate.qml
|
||||||
|
gui/SessionTree.qml
|
||||||
|
gui/ChatPane.qml
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Install ---
|
# --- Install ---
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,281 @@
|
||||||
|
import QtQuick 2.15
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
import QtQuick.Layouts 1.15
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: tree
|
||||||
|
property bool visiblePane: true
|
||||||
|
property int renamingIndex: -1
|
||||||
|
property string renamingText: ""
|
||||||
|
property string killTargetSessionId: ""
|
||||||
|
property string killTargetAgentSessionId: ""
|
||||||
|
property string killTargetAgentId: ""
|
||||||
|
signal newSessionRequested()
|
||||||
|
signal settingsRequested()
|
||||||
|
signal newAgentRequested(string sessionId)
|
||||||
|
signal renameFailed(string itemType, string attemptedName)
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
id: sidebarPane
|
||||||
|
visible: tree.visiblePane
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
ToolBar {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
Label { text: "Sessions"; Layout.fillWidth: true; font.bold: true; leftPadding: 8 }
|
||||||
|
ToolButton {
|
||||||
|
text: "\u2795"
|
||||||
|
onClicked: tree.newSessionRequested()
|
||||||
|
ToolTip.text: "New Session"
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
}
|
||||||
|
ToolButton {
|
||||||
|
text: "\u21BB"
|
||||||
|
onClicked: ollie.refreshSessions()
|
||||||
|
}
|
||||||
|
ToolButton {
|
||||||
|
text: "\u2699"
|
||||||
|
onClicked: tree.settingsRequested()
|
||||||
|
ToolTip.text: "Settings"
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: sessionList
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
model: sessionModel
|
||||||
|
clip: true
|
||||||
|
focus: false
|
||||||
|
activeFocusOnTab: false
|
||||||
|
|
||||||
|
// Show placeholder when empty or disconnected
|
||||||
|
Label {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: ollie.daemonConnected ? "(no sessions)" : "(not connected)"
|
||||||
|
color: theme.midColor
|
||||||
|
font.italic: true
|
||||||
|
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 12
|
||||||
|
visible: sessionList.count === 0 || !ollie.daemonConnected
|
||||||
|
activeFocusOnTab: false
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
width: sessionList.width
|
||||||
|
|
||||||
|
// Guard against undefined during initial render
|
||||||
|
height: {
|
||||||
|
if (!type) return 0
|
||||||
|
if (type === "header") return 24
|
||||||
|
if (type === "agent" && !parentExpanded)
|
||||||
|
return 0
|
||||||
|
return 28
|
||||||
|
}
|
||||||
|
visible: height > 0
|
||||||
|
property int modelIndex: index
|
||||||
|
property bool isHeader: type === "header"
|
||||||
|
|
||||||
|
// Headers are not interactive
|
||||||
|
enabled: !isHeader
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
hoverEnabled: false
|
||||||
|
enabled: !parent.isHeader
|
||||||
|
onClicked: function(mouse) {
|
||||||
|
if (mouse.button === Qt.RightButton) {
|
||||||
|
if (type === "session") {
|
||||||
|
var item = sessionModel.get(parent.modelIndex)
|
||||||
|
if (item)
|
||||||
|
tree.killTargetSessionId = item.id
|
||||||
|
sessionCtxMenu.popup()
|
||||||
|
} else if (type === "agent") {
|
||||||
|
var agentItem = sessionModel.get(parent.modelIndex)
|
||||||
|
if (agentItem) {
|
||||||
|
tree.killTargetAgentSessionId = agentItem.parentId
|
||||||
|
tree.killTargetAgentId = agentItem.agentId
|
||||||
|
}
|
||||||
|
agentCtxMenu.popup()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (renamingIndex >= 0) return
|
||||||
|
if (type === "session") {
|
||||||
|
// Find first agent in this session and select it
|
||||||
|
var foundAgent = null
|
||||||
|
for (var i = 0; i < sessionModel.count; ++i) {
|
||||||
|
var item = sessionModel.get(i)
|
||||||
|
if (item.type === "agent" && item.parentId === id) {
|
||||||
|
foundAgent = item
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundAgent) {
|
||||||
|
// Select the first agent
|
||||||
|
ollie.switchAgent(id, foundAgent.agentId)
|
||||||
|
} else {
|
||||||
|
// No agents - just select session (clears agent)
|
||||||
|
ollie.setActiveSessionId(id)
|
||||||
|
}
|
||||||
|
sessionModel.setExpanded(parent.modelIndex, !expanded)
|
||||||
|
} else if (type === "agent") {
|
||||||
|
ollie.switchAgent(parentId, agentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDoubleClicked: {
|
||||||
|
if (type === "session" || type === "agent") {
|
||||||
|
startRename(parent.modelIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
id: sessionCtxMenu
|
||||||
|
MenuItem {
|
||||||
|
text: "New Agent..."
|
||||||
|
onClicked: {
|
||||||
|
tree.newAgentRequested(tree.killTargetSessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuItem {
|
||||||
|
text: ollie.isSessionPaused(tree.killTargetSessionId) ? "Resume" : "Pause"
|
||||||
|
onClicked: {
|
||||||
|
if (tree.killTargetSessionId) {
|
||||||
|
if (ollie.isSessionPaused(tree.killTargetSessionId))
|
||||||
|
ollie.resumeSession(tree.killTargetSessionId)
|
||||||
|
else
|
||||||
|
ollie.pauseSession(tree.killTargetSessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuItem {
|
||||||
|
text: "Kill Session"
|
||||||
|
onClicked: {
|
||||||
|
if (tree.killTargetSessionId)
|
||||||
|
ollie.killSession(tree.killTargetSessionId)
|
||||||
|
ollie.refreshSessions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
id: agentCtxMenu
|
||||||
|
MenuItem {
|
||||||
|
text: "Kill"
|
||||||
|
onClicked: {
|
||||||
|
if (tree.killTargetAgentSessionId && tree.killTargetAgentId)
|
||||||
|
ollie.killAgent(tree.killTargetAgentSessionId, tree.killTargetAgentId)
|
||||||
|
ollie.refreshSessions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// Indent + expand/collapse indicator
|
||||||
|
Item {
|
||||||
|
Layout.preferredWidth: type === "header" ? 8 : (level || 0) * 16 + 16
|
||||||
|
Layout.preferredHeight: type === "header" ? 24 : 28
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: type === "session" ? (expanded ? "▼" : "▶") : ""
|
||||||
|
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.7 : 10
|
||||||
|
color: theme.textColor
|
||||||
|
opacity: 0.6
|
||||||
|
visible: type !== "header"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection status indicator (session rows only)
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: 8
|
||||||
|
Layout.preferredHeight: 8
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
Layout.rightMargin: 6
|
||||||
|
radius: 4
|
||||||
|
color: connectionColor || "gray"
|
||||||
|
visible: type === "session"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name area
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: type === "header" ? 24 : 28
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
anchors.fill: parent
|
||||||
|
text: (modelIndex === renamingIndex) ? renamingText : ""
|
||||||
|
visible: modelIndex === renamingIndex
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
|
||||||
|
color: theme.textColor
|
||||||
|
background: Rectangle {
|
||||||
|
color: theme.baseColor
|
||||||
|
border.color: theme.highlightColor
|
||||||
|
radius: 2
|
||||||
|
}
|
||||||
|
onTextChanged: {
|
||||||
|
if (modelIndex === renamingIndex) {
|
||||||
|
renamingText = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Keys.onReturnPressed: commitRename()
|
||||||
|
Keys.onEscapePressed: cancelRename()
|
||||||
|
onAccepted: commitRename()
|
||||||
|
onVisibleChanged: {
|
||||||
|
if (visible) {
|
||||||
|
forceActiveFocus()
|
||||||
|
selectAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.fill: parent
|
||||||
|
text: displayName || ""
|
||||||
|
color: type === "header" ? theme.midColor : theme.textColor
|
||||||
|
font.pointSize: theme.fontSize > 0 ? theme.fontSize * (type === "header" ? 0.85 : 1) : Qt.application.font.pointSize
|
||||||
|
font.bold: type === "header"
|
||||||
|
font.capitalization: type === "header" ? Font.AllUppercase : Font.MixedCase
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
visible: modelIndex !== renamingIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
highlighted: (type === "agent" && agentId === ollie.activeAgentId && parentId === ollie.activeSessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function startRename(index) {
|
||||||
|
var item = sessionModel.get(index)
|
||||||
|
if (item.type === "session" || item.type === "agent") {
|
||||||
|
tree.renamingIndex = index
|
||||||
|
tree.renamingText = item.type === "session" ? (item.baseName || item.displayName) : item.displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRename() {
|
||||||
|
if (tree.renamingIndex < 0) return
|
||||||
|
var item = sessionModel.get(tree.renamingIndex)
|
||||||
|
var name = tree.renamingText.trim()
|
||||||
|
if (name !== "") {
|
||||||
|
var ok = item.type === "session" ? ollie.renameSession(item.id, name) : ollie.renameAgent(item.parentId, item.agentId, name)
|
||||||
|
if (!ok) tree.renameFailed(item.type, name)
|
||||||
|
}
|
||||||
|
tree.renamingIndex = -1
|
||||||
|
tree.renamingText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() { tree.renamingIndex = -1; tree.renamingText = "" }
|
||||||
|
|
||||||
|
}
|
||||||
11
gui/main.cpp
11
gui/main.cpp
|
|
@ -6,6 +6,7 @@
|
||||||
#include <QQuickStyle>
|
#include <QQuickStyle>
|
||||||
|
|
||||||
#include "ollie9pclient.h"
|
#include "ollie9pclient.h"
|
||||||
|
#include "sessionmodel.h"
|
||||||
#include "chatblockmodel.h"
|
#include "chatblockmodel.h"
|
||||||
#include "thememanager.h"
|
#include "thememanager.h"
|
||||||
#include "clipboardhelper.h"
|
#include "clipboardhelper.h"
|
||||||
|
|
@ -26,17 +27,17 @@ int main(int argc, char *argv[])
|
||||||
QQmlApplicationEngine engine;
|
QQmlApplicationEngine engine;
|
||||||
|
|
||||||
auto *ollie = new Ollie9pClient(&app);
|
auto *ollie = new Ollie9pClient(&app);
|
||||||
|
auto *sessionModel = new SessionModel(ollie, &app);
|
||||||
auto *chatModel = new ChatBlockModel(&app);
|
auto *chatModel = new ChatBlockModel(&app);
|
||||||
auto *theme = new ThemeManager(&app);
|
auto *theme = new ThemeManager(&app);
|
||||||
auto *clipboard = new ClipboardHelper(&app);
|
auto *clipboard = new ClipboardHelper(&app);
|
||||||
|
|
||||||
// Route chat stream to the block model.
|
QObject::connect(ollie, &Ollie9pClient::chatReceived,
|
||||||
// Reset + reload is handled in QML's onActiveSessionIdChanged so the
|
chatModel, &ChatBlockModel::appendData);
|
||||||
// readLogForSession 9P call is sequenced correctly; the C++ slot would
|
|
||||||
// fire before the QML handler and race against the log read.
|
|
||||||
QObject::connect(ollie, &Ollie9pClient::chatReceived, chatModel, &ChatBlockModel::appendData);
|
|
||||||
|
|
||||||
engine.rootContext()->setContextProperty("ollie", ollie);
|
engine.rootContext()->setContextProperty("ollie", ollie);
|
||||||
|
engine.rootContext()->setContextProperty("sessionModel", sessionModel);
|
||||||
engine.rootContext()->setContextProperty("chatModel", chatModel);
|
engine.rootContext()->setContextProperty("chatModel", chatModel);
|
||||||
engine.rootContext()->setContextProperty("theme", theme);
|
engine.rootContext()->setContextProperty("theme", theme);
|
||||||
engine.rootContext()->setContextProperty("clipboard", clipboard);
|
engine.rootContext()->setContextProperty("clipboard", clipboard);
|
||||||
|
|
|
||||||
912
gui/main.qml
912
gui/main.qml
|
|
@ -67,8 +67,8 @@ ApplicationWindow {
|
||||||
Shortcut {
|
Shortcut {
|
||||||
sequence: "Escape"
|
sequence: "Escape"
|
||||||
onActivated: {
|
onActivated: {
|
||||||
if (renamingIndex >= 0) {
|
if (sessionTree.renamingIndex >= 0) {
|
||||||
cancelRename()
|
sessionTree.cancelRename()
|
||||||
} else {
|
} else {
|
||||||
// Always send stop - server-side Interrupt() is safe to call
|
// Always send stop - server-side Interrupt() is safe to call
|
||||||
// on an idle agent (no-op). This avoids races where the GUI's
|
// on an idle agent (no-op). This avoids races where the GUI's
|
||||||
|
|
@ -78,415 +78,8 @@ ApplicationWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sidebar visibility
|
|
||||||
property bool sidebarVisible: true
|
property bool sidebarVisible: true
|
||||||
|
|
||||||
// Session ID to kill — set on right-click, consumed by context menu
|
|
||||||
property string killTargetSessionId: ""
|
|
||||||
// Target session for "New Agent" dialog
|
|
||||||
property string newAgentTargetSessionId: ""
|
|
||||||
// Agent kill target — set on right-click
|
|
||||||
property string killTargetAgentSessionId: ""
|
|
||||||
property string killTargetAgentId: ""
|
|
||||||
|
|
||||||
// Session tree model — flat list with level info
|
|
||||||
ListModel {
|
|
||||||
id: sessionTreeModel
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache for session expanded state (sessionId -> bool)
|
|
||||||
property var sessionExpandedCache: ({})
|
|
||||||
|
|
||||||
// Cache of current model structure for diffing: { sessionId: { agents: [agentId, ...] } }
|
|
||||||
property var modelCache: ({})
|
|
||||||
|
|
||||||
function buildSessionTree() {
|
|
||||||
// Full rebuild - used on initial load
|
|
||||||
var focusItem = root.activeFocusItem
|
|
||||||
sessionTreeModel.clear()
|
|
||||||
modelCache = {}
|
|
||||||
var sessions = ollie.sessions
|
|
||||||
|
|
||||||
// Sessions are already sorted: active first, paused last.
|
|
||||||
// Insert group headers when paused state changes.
|
|
||||||
var lastPaused = null
|
|
||||||
for (var i = 0; i < sessions.length; ++i) {
|
|
||||||
var sess = sessions[i]
|
|
||||||
var isPaused = sess.paused || false
|
|
||||||
|
|
||||||
// Insert header on transition
|
|
||||||
if (lastPaused === null && !isPaused) {
|
|
||||||
sessionTreeModel.append({
|
|
||||||
type: "header",
|
|
||||||
level: 0,
|
|
||||||
displayName: "Active"
|
|
||||||
})
|
|
||||||
} else if (lastPaused === false && isPaused) {
|
|
||||||
sessionTreeModel.append({
|
|
||||||
type: "header",
|
|
||||||
level: 0,
|
|
||||||
displayName: "Inactive"
|
|
||||||
})
|
|
||||||
} else if (lastPaused === null && isPaused) {
|
|
||||||
// All sessions are paused - show Inactive header
|
|
||||||
sessionTreeModel.append({
|
|
||||||
type: "header",
|
|
||||||
level: 0,
|
|
||||||
displayName: "Inactive"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
lastPaused = isPaused
|
|
||||||
|
|
||||||
appendSessionToModel(sess)
|
|
||||||
}
|
|
||||||
// Restore focus to whatever had it (handles dialogs, not just promptInput)
|
|
||||||
if (focusItem && focusItem.visible)
|
|
||||||
focusItem.forceActiveFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute connection color from session data (avoids C++ call)
|
|
||||||
function computeConnectionColor(sess) {
|
|
||||||
var paused = sess.paused || false
|
|
||||||
var connected = sess.connected || false
|
|
||||||
if (paused && connected) return "yellow" // zombie
|
|
||||||
if (paused) return "gray" // intentionally paused
|
|
||||||
if (connected) return "green" // healthy
|
|
||||||
return "red" // crashed/broken
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendSessionToModel(sess) {
|
|
||||||
var sessionId = sess.id || ""
|
|
||||||
var sessionName = sess.name || sessionId
|
|
||||||
var agents = sess.agents || []
|
|
||||||
var agentCount = agents.length
|
|
||||||
var displayLabel = agentCount > 0 ? sessionName + " (" + agentCount + ")" : sessionName
|
|
||||||
var wasExpanded = sessionExpandedCache[sessionId] !== undefined ? sessionExpandedCache[sessionId] : true
|
|
||||||
sessionExpandedCache[sessionId] = wasExpanded
|
|
||||||
var isPaused = sess.paused || false
|
|
||||||
|
|
||||||
sessionTreeModel.append({
|
|
||||||
type: "session",
|
|
||||||
id: sessionId,
|
|
||||||
level: 0,
|
|
||||||
expanded: wasExpanded,
|
|
||||||
state: sess.state || "",
|
|
||||||
model: sess.model || "",
|
|
||||||
displayName: displayLabel,
|
|
||||||
baseName: sessionName,
|
|
||||||
connectionColor: computeConnectionColor(sess),
|
|
||||||
paused: isPaused
|
|
||||||
})
|
|
||||||
|
|
||||||
var agentIds = []
|
|
||||||
for (var j = 0; j < agents.length; ++j) {
|
|
||||||
var agent = agents[j]
|
|
||||||
// Use state from session/idx data directly (already contains agent state)
|
|
||||||
sessionTreeModel.append({
|
|
||||||
type: "agent",
|
|
||||||
parentId: sessionId,
|
|
||||||
agentId: agent.id,
|
|
||||||
level: 1,
|
|
||||||
state: agent.state || "idle",
|
|
||||||
displayName: agent.name,
|
|
||||||
parentExpanded: wasExpanded,
|
|
||||||
parentPaused: isPaused
|
|
||||||
})
|
|
||||||
agentIds.push(agent.id)
|
|
||||||
}
|
|
||||||
modelCache[sessionId] = { agents: agentIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSessionTree() {
|
|
||||||
// Incremental update - diff old vs new
|
|
||||||
var focusItem = root.activeFocusItem
|
|
||||||
var sessions = ollie.sessions
|
|
||||||
|
|
||||||
// Build lookup of new sessions
|
|
||||||
var newSessionIds = {}
|
|
||||||
for (var i = 0; i < sessions.length; ++i) {
|
|
||||||
newSessionIds[sessions[i].id] = sessions[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build lookup of old sessions from cache
|
|
||||||
var oldSessionIds = Object.keys(modelCache)
|
|
||||||
|
|
||||||
// Find removed sessions and delete from model (iterate backwards to preserve indices)
|
|
||||||
for (var idx = sessionTreeModel.count - 1; idx >= 0; --idx) {
|
|
||||||
var item = sessionTreeModel.get(idx)
|
|
||||||
if (item.type === "session" && !newSessionIds[item.id]) {
|
|
||||||
// Remove session and all its agents
|
|
||||||
removeSessionFromModel(idx, item.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing sessions and add new ones
|
|
||||||
for (var j = 0; j < sessions.length; ++j) {
|
|
||||||
var sess = sessions[j]
|
|
||||||
var sessionId = sess.id
|
|
||||||
|
|
||||||
if (modelCache[sessionId]) {
|
|
||||||
// Session exists - update properties
|
|
||||||
updateSessionInModel(sess)
|
|
||||||
} else {
|
|
||||||
// New session - append at end
|
|
||||||
appendSessionToModel(sess)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore focus to whatever had it (handles dialogs, not just promptInput)
|
|
||||||
if (focusItem && focusItem.visible)
|
|
||||||
focusItem.forceActiveFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
function findSessionIndex(sessionId) {
|
|
||||||
for (var i = 0; i < sessionTreeModel.count; ++i) {
|
|
||||||
var item = sessionTreeModel.get(i)
|
|
||||||
if (item.type === "session" && item.id === sessionId)
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeSessionFromModel(sessionIdx, sessionId) {
|
|
||||||
// Count agents to remove (they follow the session)
|
|
||||||
var removeCount = 1
|
|
||||||
for (var i = sessionIdx + 1; i < sessionTreeModel.count; ++i) {
|
|
||||||
var item = sessionTreeModel.get(i)
|
|
||||||
if (item.type === "session") break
|
|
||||||
if (item.type === "agent" && item.parentId === sessionId)
|
|
||||||
removeCount++
|
|
||||||
}
|
|
||||||
// Remove session + agents
|
|
||||||
for (var r = 0; r < removeCount; ++r) {
|
|
||||||
sessionTreeModel.remove(sessionIdx)
|
|
||||||
}
|
|
||||||
delete modelCache[sessionId]
|
|
||||||
delete sessionExpandedCache[sessionId]
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSessionInModel(sess) {
|
|
||||||
var sessionId = sess.id
|
|
||||||
var sessionIdx = findSessionIndex(sessionId)
|
|
||||||
if (sessionIdx < 0) return
|
|
||||||
|
|
||||||
var sessionName = sess.name || sessionId
|
|
||||||
var agents = sess.agents || []
|
|
||||||
var agentCount = agents.length
|
|
||||||
var displayLabel = agentCount > 0 ? sessionName + " (" + agentCount + ")" : sessionName
|
|
||||||
var isPaused = sess.paused || false
|
|
||||||
var wasExpanded = sessionExpandedCache[sessionId] !== undefined ? sessionExpandedCache[sessionId] : true
|
|
||||||
|
|
||||||
// Update session properties (use QML-computed color, avoids C++ call)
|
|
||||||
sessionTreeModel.setProperty(sessionIdx, "displayName", displayLabel)
|
|
||||||
sessionTreeModel.setProperty(sessionIdx, "baseName", sessionName)
|
|
||||||
sessionTreeModel.setProperty(sessionIdx, "state", sess.state || "")
|
|
||||||
sessionTreeModel.setProperty(sessionIdx, "model", sess.model || "")
|
|
||||||
sessionTreeModel.setProperty(sessionIdx, "connectionColor", computeConnectionColor(sess))
|
|
||||||
sessionTreeModel.setProperty(sessionIdx, "paused", isPaused)
|
|
||||||
|
|
||||||
// Build agent lookup
|
|
||||||
var newAgentIds = {}
|
|
||||||
for (var i = 0; i < agents.length; ++i) {
|
|
||||||
newAgentIds[agents[i].id] = agents[i]
|
|
||||||
}
|
|
||||||
var oldAgentIds = modelCache[sessionId] ? modelCache[sessionId].agents : []
|
|
||||||
|
|
||||||
// Update/remove existing agents
|
|
||||||
for (var idx = sessionIdx + 1; idx < sessionTreeModel.count; ++idx) {
|
|
||||||
var item = sessionTreeModel.get(idx)
|
|
||||||
if (item.type === "session") break
|
|
||||||
if (item.type === "agent" && item.parentId === sessionId) {
|
|
||||||
if (newAgentIds[item.agentId]) {
|
|
||||||
// Update agent - use state from session/idx directly
|
|
||||||
var agent = newAgentIds[item.agentId]
|
|
||||||
sessionTreeModel.setProperty(idx, "state", agent.state || "idle")
|
|
||||||
sessionTreeModel.setProperty(idx, "displayName", agent.name)
|
|
||||||
sessionTreeModel.setProperty(idx, "parentPaused", isPaused)
|
|
||||||
} else {
|
|
||||||
// Remove agent
|
|
||||||
sessionTreeModel.remove(idx)
|
|
||||||
--idx // Adjust for removed item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new agents (at end of session's agents)
|
|
||||||
var insertIdx = sessionIdx + 1
|
|
||||||
for (var k = insertIdx; k < sessionTreeModel.count; ++k) {
|
|
||||||
var checkItem = sessionTreeModel.get(k)
|
|
||||||
if (checkItem.type === "session") break
|
|
||||||
insertIdx = k + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
var newAgentList = []
|
|
||||||
for (var m = 0; m < agents.length; ++m) {
|
|
||||||
var ag = agents[m]
|
|
||||||
newAgentList.push(ag.id)
|
|
||||||
var found = false
|
|
||||||
for (var n = 0; n < oldAgentIds.length; ++n) {
|
|
||||||
if (oldAgentIds[n] === ag.id) { found = true; break }
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
// Use state from session/idx directly
|
|
||||||
sessionTreeModel.insert(insertIdx, {
|
|
||||||
type: "agent",
|
|
||||||
parentId: sessionId,
|
|
||||||
agentId: ag.id,
|
|
||||||
level: 1,
|
|
||||||
state: ag.state || "idle",
|
|
||||||
displayName: ag.name,
|
|
||||||
parentExpanded: wasExpanded,
|
|
||||||
parentPaused: isPaused
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
modelCache[sessionId] = { agents: newAgentList }
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleExpand(index) {
|
|
||||||
var item = sessionTreeModel.get(index)
|
|
||||||
if (!item.expanded === undefined) return // only expandable items
|
|
||||||
|
|
||||||
var newExpanded = !item.expanded
|
|
||||||
var itemLevel = item.level || 0
|
|
||||||
sessionTreeModel.setProperty(index, "expanded", newExpanded)
|
|
||||||
|
|
||||||
// Cache for sessions (by id)
|
|
||||||
if (item.type === "session" && item.id) {
|
|
||||||
sessionExpandedCache[item.id] = newExpanded
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update all descendants: items with level > itemLevel until we hit same or lower level
|
|
||||||
for (var i = index + 1; i < sessionTreeModel.count; ++i) {
|
|
||||||
var child = sessionTreeModel.get(i)
|
|
||||||
var childLevel = child.level || 0
|
|
||||||
if (childLevel <= itemLevel) break // end of descendants
|
|
||||||
|
|
||||||
// Determine visibility: walk up to find if any ancestor is collapsed
|
|
||||||
var visible = newExpanded
|
|
||||||
if (visible) {
|
|
||||||
// Check all ancestors between this item and the child
|
|
||||||
for (var j = i - 1; j >= index; --j) {
|
|
||||||
var ancestor = sessionTreeModel.get(j)
|
|
||||||
var ancestorLevel = ancestor.level || 0
|
|
||||||
if (ancestorLevel < childLevel && ancestor.expanded === false) {
|
|
||||||
visible = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (ancestorLevel < childLevel) {
|
|
||||||
childLevel = ancestorLevel // track the level we've checked up to
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sessionTreeModel.setProperty(i, "parentExpanded", visible)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateAgent(parentId, agentId) {
|
|
||||||
ollie.switchAgent(parentId, agentId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename tracking
|
|
||||||
property int renamingIndex: -1
|
|
||||||
property string renamingText: ""
|
|
||||||
|
|
||||||
function startRename(index) {
|
|
||||||
var item = sessionTreeModel.get(index)
|
|
||||||
if (item.type === "session" || item.type === "agent") {
|
|
||||||
renamingIndex = index
|
|
||||||
// Use baseName for sessions (without agent count), displayName for agents
|
|
||||||
renamingText = item.type === "session" ? (item.baseName || item.displayName) : item.displayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitRename() {
|
|
||||||
if (renamingIndex < 0) return
|
|
||||||
var item = sessionTreeModel.get(renamingIndex)
|
|
||||||
var newName = renamingText.trim()
|
|
||||||
// For sessions, compare against baseName (without count)
|
|
||||||
var oldName = item.type === "session" ? (item.baseName || item.displayName) : item.displayName
|
|
||||||
if (newName !== "" && newName !== oldName) {
|
|
||||||
var success = false
|
|
||||||
if (item.type === "session") {
|
|
||||||
success = ollie.renameSession(item.id, newName)
|
|
||||||
// Don't update displayName here - let refreshSessions rebuild with count
|
|
||||||
} else if (item.type === "agent") {
|
|
||||||
success = ollie.renameAgent(item.parentId, item.agentId, newName)
|
|
||||||
if (success) {
|
|
||||||
sessionTreeModel.set(renamingIndex, { agentId: newName, displayName: newName })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!success) {
|
|
||||||
renameErrorDialog.itemType = item.type
|
|
||||||
renameErrorDialog.attemptedName = newName
|
|
||||||
renameErrorDialog.open()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
renamingIndex = -1
|
|
||||||
renamingText = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelRename() {
|
|
||||||
renamingIndex = -1
|
|
||||||
renamingText = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: ollie
|
|
||||||
function onSessionsChanged() {
|
|
||||||
// Always do full rebuild since headers complicate incremental updates
|
|
||||||
buildSessionTree()
|
|
||||||
}
|
|
||||||
function onAgentStateChanged(sessionId, agentId, state) {
|
|
||||||
// Update only the specific agent in the model
|
|
||||||
for (var i = 0; i < sessionTreeModel.count; ++i) {
|
|
||||||
var item = sessionTreeModel.get(i)
|
|
||||||
if (item.type === "agent" && item.parentId === sessionId && item.agentId === agentId) {
|
|
||||||
sessionTreeModel.setProperty(i, "state", state)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
|
||||||
if (ollie.activeAgentId) {
|
|
||||||
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
|
|
||||||
if (log) {
|
|
||||||
chatModel.appendData(log)
|
|
||||||
chatModel.trimFront(20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ollie.refreshSessions()
|
|
||||||
buildSessionTree()
|
|
||||||
}
|
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: ollie
|
|
||||||
function onActiveSessionIdChanged() {
|
|
||||||
chatModel.reset()
|
|
||||||
chatView.followTail = true
|
|
||||||
if (ollie.activeSessionId && ollie.activeAgentId) {
|
|
||||||
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
|
|
||||||
if (log) {
|
|
||||||
chatModel.appendData(log)
|
|
||||||
chatModel.trimFront(20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function onActiveAgentIdChanged() {
|
|
||||||
chatModel.reset()
|
|
||||||
chatView.followTail = true
|
|
||||||
if (ollie.activeSessionId && ollie.activeAgentId) {
|
|
||||||
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
|
|
||||||
if (log) {
|
|
||||||
chatModel.appendData(log)
|
|
||||||
chatModel.trimFront(20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Rename Error Dialog ---
|
// --- Rename Error Dialog ---
|
||||||
Dialog {
|
Dialog {
|
||||||
id: renameErrorDialog
|
id: renameErrorDialog
|
||||||
|
|
@ -518,500 +111,35 @@ ApplicationWindow {
|
||||||
// --- New Agent Dialog ---
|
// --- New Agent Dialog ---
|
||||||
NewAgentDialog {
|
NewAgentDialog {
|
||||||
id: newAgentDialog
|
id: newAgentDialog
|
||||||
targetSessionId: root.newAgentTargetSessionId
|
targetSessionId: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
SplitView {
|
SplitView {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
||||||
// Left pane: session list
|
SessionTree {
|
||||||
ColumnLayout {
|
id: sessionTree
|
||||||
id: sidebarPane
|
|
||||||
SplitView.preferredWidth: root.sidebarVisible ? root.width * 0.15 : 0
|
SplitView.preferredWidth: root.sidebarVisible ? root.width * 0.15 : 0
|
||||||
SplitView.minimumWidth: root.sidebarVisible ? 300 : 0
|
SplitView.minimumWidth: root.sidebarVisible ? 300 : 0
|
||||||
SplitView.maximumWidth: root.sidebarVisible ? Infinity : 0
|
visiblePane: root.sidebarVisible
|
||||||
visible: root.sidebarVisible
|
onNewSessionRequested: newSessionDialog.open()
|
||||||
spacing: 0
|
onSettingsRequested: settingsDialog.open()
|
||||||
|
onNewAgentRequested: function(sessionId) {
|
||||||
ToolBar {
|
newAgentDialog.targetSessionId = sessionId
|
||||||
Layout.fillWidth: true
|
newAgentDialog.open()
|
||||||
RowLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
Label { text: "Sessions"; Layout.fillWidth: true; font.bold: true; leftPadding: 8 }
|
|
||||||
ToolButton {
|
|
||||||
text: "\u2795"
|
|
||||||
onClicked: newSessionDialog.open()
|
|
||||||
ToolTip.text: "New Session"
|
|
||||||
ToolTip.visible: hovered
|
|
||||||
}
|
|
||||||
ToolButton {
|
|
||||||
text: "\u21BB"
|
|
||||||
onClicked: ollie.refreshSessions()
|
|
||||||
}
|
|
||||||
ToolButton {
|
|
||||||
text: "\u2699"
|
|
||||||
onClicked: settingsDialog.open()
|
|
||||||
ToolTip.text: "Settings"
|
|
||||||
ToolTip.visible: hovered
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
onRenameFailed: function(itemType, attemptedName) {
|
||||||
ListView {
|
renameErrorDialog.itemType = itemType
|
||||||
id: sessionList
|
renameErrorDialog.attemptedName = attemptedName
|
||||||
Layout.fillWidth: true
|
renameErrorDialog.open()
|
||||||
Layout.fillHeight: true
|
|
||||||
model: sessionTreeModel
|
|
||||||
clip: true
|
|
||||||
focus: false
|
|
||||||
activeFocusOnTab: false
|
|
||||||
|
|
||||||
// Show placeholder when empty or disconnected
|
|
||||||
Label {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: ollie.daemonConnected ? "(no sessions)" : "(not connected)"
|
|
||||||
color: theme.midColor
|
|
||||||
font.italic: true
|
|
||||||
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 12
|
|
||||||
visible: sessionList.count === 0 || !ollie.daemonConnected
|
|
||||||
activeFocusOnTab: false
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate: ItemDelegate {
|
|
||||||
width: sessionList.width
|
|
||||||
|
|
||||||
// Guard against undefined during initial render
|
|
||||||
height: {
|
|
||||||
if (!type) return 0
|
|
||||||
if (type === "header") return 24
|
|
||||||
if (type === "agent" && !parentExpanded)
|
|
||||||
return 0
|
|
||||||
return 28
|
|
||||||
}
|
|
||||||
visible: height > 0
|
|
||||||
property int modelIndex: index
|
|
||||||
property bool isHeader: type === "header"
|
|
||||||
|
|
||||||
// Headers are not interactive
|
|
||||||
enabled: !isHeader
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
||||||
hoverEnabled: false
|
|
||||||
enabled: !parent.isHeader
|
|
||||||
onClicked: function(mouse) {
|
|
||||||
if (mouse.button === Qt.RightButton) {
|
|
||||||
if (type === "session") {
|
|
||||||
var item = sessionTreeModel.get(parent.modelIndex)
|
|
||||||
if (item)
|
|
||||||
root.killTargetSessionId = item.id
|
|
||||||
sessionCtxMenu.popup()
|
|
||||||
} else if (type === "agent") {
|
|
||||||
var agentItem = sessionTreeModel.get(parent.modelIndex)
|
|
||||||
if (agentItem) {
|
|
||||||
root.killTargetAgentSessionId = agentItem.parentId
|
|
||||||
root.killTargetAgentId = agentItem.agentId
|
|
||||||
}
|
|
||||||
agentCtxMenu.popup()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (renamingIndex >= 0) return
|
|
||||||
if (type === "session") {
|
|
||||||
// Find first agent in this session and select it
|
|
||||||
var foundAgent = null
|
|
||||||
for (var i = 0; i < sessionTreeModel.count; ++i) {
|
|
||||||
var item = sessionTreeModel.get(i)
|
|
||||||
if (item.type === "agent" && item.parentId === id) {
|
|
||||||
foundAgent = item
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (foundAgent) {
|
|
||||||
// Select the first agent
|
|
||||||
activateAgent(id, foundAgent.agentId)
|
|
||||||
} else {
|
|
||||||
// No agents - just select session (clears agent)
|
|
||||||
ollie.setActiveSessionId(id)
|
|
||||||
}
|
|
||||||
toggleExpand(parent.modelIndex)
|
|
||||||
} else if (type === "agent") {
|
|
||||||
activateAgent(parentId, agentId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onDoubleClicked: {
|
|
||||||
if (type === "session" || type === "agent") {
|
|
||||||
startRename(parent.modelIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Menu {
|
|
||||||
id: sessionCtxMenu
|
|
||||||
MenuItem {
|
|
||||||
text: "New Agent..."
|
|
||||||
onClicked: {
|
|
||||||
root.newAgentTargetSessionId = root.killTargetSessionId
|
|
||||||
newAgentDialog.open()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MenuItem {
|
|
||||||
text: ollie.isSessionPaused(root.killTargetSessionId) ? "Resume" : "Pause"
|
|
||||||
onClicked: {
|
|
||||||
if (root.killTargetSessionId) {
|
|
||||||
if (ollie.isSessionPaused(root.killTargetSessionId))
|
|
||||||
ollie.resumeSession(root.killTargetSessionId)
|
|
||||||
else
|
|
||||||
ollie.pauseSession(root.killTargetSessionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MenuItem {
|
|
||||||
text: "Kill Session"
|
|
||||||
onClicked: {
|
|
||||||
if (root.killTargetSessionId)
|
|
||||||
ollie.killSession(root.killTargetSessionId)
|
|
||||||
ollie.refreshSessions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Menu {
|
|
||||||
id: agentCtxMenu
|
|
||||||
MenuItem {
|
|
||||||
text: "Kill"
|
|
||||||
onClicked: {
|
|
||||||
if (root.killTargetAgentSessionId && root.killTargetAgentId)
|
|
||||||
ollie.killAgent(root.killTargetAgentSessionId, root.killTargetAgentId)
|
|
||||||
ollie.refreshSessions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
contentItem: RowLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
// Indent + expand/collapse indicator
|
|
||||||
Item {
|
|
||||||
Layout.preferredWidth: type === "header" ? 8 : (level || 0) * 16 + 16
|
|
||||||
Layout.preferredHeight: type === "header" ? 24 : 28
|
|
||||||
|
|
||||||
Label {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: type === "session" ? (expanded ? "▼" : "▶") : ""
|
|
||||||
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.7 : 10
|
|
||||||
color: theme.textColor
|
|
||||||
opacity: 0.6
|
|
||||||
visible: type !== "header"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection status indicator (session rows only)
|
|
||||||
Rectangle {
|
|
||||||
Layout.preferredWidth: 8
|
|
||||||
Layout.preferredHeight: 8
|
|
||||||
Layout.alignment: Qt.AlignVCenter
|
|
||||||
Layout.rightMargin: 6
|
|
||||||
radius: 4
|
|
||||||
color: connectionColor || "gray"
|
|
||||||
visible: type === "session"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name area
|
|
||||||
Item {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: type === "header" ? 24 : 28
|
|
||||||
|
|
||||||
TextField {
|
|
||||||
anchors.fill: parent
|
|
||||||
text: (modelIndex === renamingIndex) ? renamingText : ""
|
|
||||||
visible: modelIndex === renamingIndex
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
|
|
||||||
color: theme.textColor
|
|
||||||
background: Rectangle {
|
|
||||||
color: theme.baseColor
|
|
||||||
border.color: theme.highlightColor
|
|
||||||
radius: 2
|
|
||||||
}
|
|
||||||
onTextChanged: {
|
|
||||||
if (modelIndex === renamingIndex) {
|
|
||||||
renamingText = text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Keys.onReturnPressed: commitRename()
|
|
||||||
Keys.onEscapePressed: cancelRename()
|
|
||||||
onAccepted: commitRename()
|
|
||||||
onVisibleChanged: {
|
|
||||||
if (visible) {
|
|
||||||
forceActiveFocus()
|
|
||||||
selectAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
anchors.fill: parent
|
|
||||||
text: displayName || ""
|
|
||||||
color: type === "header" ? theme.midColor : theme.textColor
|
|
||||||
font.pointSize: theme.fontSize > 0 ? theme.fontSize * (type === "header" ? 0.85 : 1) : Qt.application.font.pointSize
|
|
||||||
font.bold: type === "header"
|
|
||||||
font.capitalization: type === "header" ? Font.AllUppercase : Font.MixedCase
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
visible: modelIndex !== renamingIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
highlighted: (type === "agent" && agentId === ollie.activeAgentId && parentId === ollie.activeSessionId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right pane: chat + input
|
ChatPane {
|
||||||
ColumnLayout {
|
SplitView.fillWidth: true
|
||||||
spacing: 0
|
SplitView.fillHeight: true
|
||||||
|
sidebarVisible: root.sidebarVisible
|
||||||
// Header - shows active session/agent and state
|
onToggleSidebar: root.sidebarVisible = !root.sidebarVisible
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.margins: 6
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
// Sidebar toggle button
|
|
||||||
ToolButton {
|
|
||||||
text: root.sidebarVisible ? "◀" : "▶"
|
|
||||||
font.pixelSize: 12
|
|
||||||
implicitWidth: 24
|
|
||||||
implicitHeight: 24
|
|
||||||
onClicked: root.sidebarVisible = !root.sidebarVisible
|
|
||||||
ToolTip.text: root.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 < sessionTreeModel.count; ++i) {
|
|
||||||
var item = sessionTreeModel.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,22 @@ void Ollie9pClient::refreshSessions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-select an agent for the active session when none is selected.
|
||||||
|
// This also handles the first refresh after a session was selected before
|
||||||
|
// its agent list arrived.
|
||||||
|
if (!m_activeSessionId.isEmpty() && m_agentId.isEmpty()) {
|
||||||
|
for (const QVariant &value : m_sessions) {
|
||||||
|
const QVariantMap session = value.toMap();
|
||||||
|
if (session.value("id").toString() != m_activeSessionId) continue;
|
||||||
|
const QVariantList agents = session.value("agents").toList();
|
||||||
|
if (!agents.isEmpty()) {
|
||||||
|
const QString aid = agents.first().toMap().value("id").toString();
|
||||||
|
if (!aid.isEmpty()) switchAgent(m_activeSessionId, aid);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-select first session and its first agent if none active
|
// Auto-select first session and its first agent if none active
|
||||||
if (m_activeSessionId.isEmpty() && !m_sessions.isEmpty()) {
|
if (m_activeSessionId.isEmpty() && !m_sessions.isEmpty()) {
|
||||||
const QVariantMap firstSession = m_sessions.first().toMap();
|
const QVariantMap firstSession = m_sessions.first().toMap();
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,7 @@
|
||||||
<file>NewSessionDialog.qml</file>
|
<file>NewSessionDialog.qml</file>
|
||||||
<file>NewAgentDialog.qml</file>
|
<file>NewAgentDialog.qml</file>
|
||||||
<file>ChatBlockDelegate.qml</file>
|
<file>ChatBlockDelegate.qml</file>
|
||||||
|
<file>SessionTree.qml</file>
|
||||||
|
<file>ChatPane.qml</file>
|
||||||
</qresource>
|
</qresource>
|
||||||
</RCC>
|
</RCC>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
#include "sessionmodel.h"
|
||||||
|
#include "ollie9pclient.h"
|
||||||
|
|
||||||
|
SessionModel::SessionModel(Ollie9pClient *client, QObject *parent)
|
||||||
|
: QAbstractListModel(parent), m_client(client)
|
||||||
|
{
|
||||||
|
connect(m_client, &Ollie9pClient::sessionsChanged, this, &SessionModel::rebuild);
|
||||||
|
connect(m_client, &Ollie9pClient::agentStateChanged, this, &SessionModel::updateAgentState);
|
||||||
|
rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
int SessionModel::rowCount(const QModelIndex &parent) const
|
||||||
|
{
|
||||||
|
return parent.isValid() ? 0 : m_rows.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant SessionModel::data(const QModelIndex &index, int role) const
|
||||||
|
{
|
||||||
|
if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) return {};
|
||||||
|
const Row &r = m_rows.at(index.row());
|
||||||
|
switch (role) {
|
||||||
|
case TypeRole: return r.type;
|
||||||
|
case IdRole: return r.id;
|
||||||
|
case ParentIdRole: return r.parentId;
|
||||||
|
case AgentIdRole: return r.agentId;
|
||||||
|
case LevelRole: return r.level;
|
||||||
|
case ExpandedRole: return r.expanded;
|
||||||
|
case ParentExpandedRole: return r.parentExpanded;
|
||||||
|
case StateRole: return r.state;
|
||||||
|
case ModelRole: return r.model;
|
||||||
|
case DisplayNameRole: return r.displayName;
|
||||||
|
case BaseNameRole: return r.baseName;
|
||||||
|
case ConnectionColorRole: return r.connectionColor;
|
||||||
|
case PausedRole: return r.paused;
|
||||||
|
default: return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<int, QByteArray> SessionModel::roleNames() const
|
||||||
|
{
|
||||||
|
return {{TypeRole, "type"}, {IdRole, "id"}, {ParentIdRole, "parentId"},
|
||||||
|
{AgentIdRole, "agentId"}, {LevelRole, "level"}, {ExpandedRole, "expanded"},
|
||||||
|
{ParentExpandedRole, "parentExpanded"}, {StateRole, "state"}, {ModelRole, "model"},
|
||||||
|
{DisplayNameRole, "displayName"}, {BaseNameRole, "baseName"},
|
||||||
|
{ConnectionColorRole, "connectionColor"}, {PausedRole, "paused"}};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SessionModel::connectionColor(const QVariantMap &session)
|
||||||
|
{
|
||||||
|
const bool paused = session.value("paused").toBool();
|
||||||
|
const bool connected = session.value("connected").toBool();
|
||||||
|
if (paused && connected) return "yellow";
|
||||||
|
if (paused) return "gray";
|
||||||
|
return connected ? "green" : "red";
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionModel::appendSession(const QVariantMap &session)
|
||||||
|
{
|
||||||
|
const QString id = session.value("id").toString();
|
||||||
|
const QString name = session.value("name").toString().isEmpty() ? id : session.value("name").toString();
|
||||||
|
const QVariantList agents = session.value("agents").toList();
|
||||||
|
const bool paused = session.value("paused").toBool();
|
||||||
|
const bool expanded = m_expanded.value(id, true);
|
||||||
|
m_expanded[id] = expanded;
|
||||||
|
|
||||||
|
Row row;
|
||||||
|
row.type = "session"; row.id = id; row.expanded = expanded; row.paused = paused;
|
||||||
|
row.state = session.value("state").toString(); row.model = session.value("model").toString();
|
||||||
|
row.baseName = name;
|
||||||
|
row.displayName = agents.isEmpty() ? name : name + " (" + QString::number(agents.size()) + ")";
|
||||||
|
row.connectionColor = connectionColor(session);
|
||||||
|
m_rows.append(row);
|
||||||
|
|
||||||
|
for (const QVariant &value : agents) {
|
||||||
|
const QVariantMap agent = value.toMap();
|
||||||
|
Row child;
|
||||||
|
child.type = "agent"; child.parentId = id; child.agentId = agent.value("id").toString();
|
||||||
|
child.level = 1; child.parentExpanded = expanded;
|
||||||
|
child.state = agent.value("state").toString();
|
||||||
|
if (child.state.isEmpty()) child.state = "idle";
|
||||||
|
child.displayName = agent.value("name").toString(); child.paused = paused;
|
||||||
|
m_rows.append(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionModel::rebuild()
|
||||||
|
{
|
||||||
|
beginResetModel();
|
||||||
|
m_rows.clear();
|
||||||
|
QSet<QString> present;
|
||||||
|
bool haveActive = false;
|
||||||
|
bool haveInactive = false;
|
||||||
|
for (const QVariant &value : m_client->sessions()) {
|
||||||
|
const QVariantMap session = value.toMap();
|
||||||
|
const bool paused = session.value("paused").toBool();
|
||||||
|
if (paused && !haveInactive) {
|
||||||
|
Row header; header.type = "header"; header.displayName = "Inactive"; m_rows.append(header); haveInactive = true;
|
||||||
|
} else if (!paused && !haveActive) {
|
||||||
|
Row header; header.type = "header"; header.displayName = "Active"; m_rows.append(header); haveActive = true;
|
||||||
|
}
|
||||||
|
const QString id = session.value("id").toString();
|
||||||
|
present.insert(id);
|
||||||
|
appendSession(session);
|
||||||
|
}
|
||||||
|
for (auto it = m_expanded.begin(); it != m_expanded.end();) {
|
||||||
|
if (!present.contains(it.key())) it = m_expanded.erase(it); else ++it;
|
||||||
|
}
|
||||||
|
endResetModel();
|
||||||
|
emit countChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionModel::updateVisibility()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < m_rows.size(); ++i) {
|
||||||
|
if (m_rows[i].type != "session") continue;
|
||||||
|
for (int j = i + 1; j < m_rows.size() && m_rows[j].type != "session"; ++j)
|
||||||
|
m_rows[j].parentExpanded = m_rows[i].expanded;
|
||||||
|
}
|
||||||
|
if (!m_rows.isEmpty()) emit dataChanged(index(0), index(m_rows.size() - 1), {ParentExpandedRole, ExpandedRole});
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionModel::setExpanded(int index, bool expanded)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= m_rows.size() || m_rows[index].type != "session") return;
|
||||||
|
m_rows[index].expanded = expanded;
|
||||||
|
m_expanded[m_rows[index].id] = expanded;
|
||||||
|
updateVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap SessionModel::get(int index) const
|
||||||
|
{
|
||||||
|
QVariantMap result;
|
||||||
|
if (index < 0 || index >= m_rows.size()) return result;
|
||||||
|
const Row &r = m_rows.at(index);
|
||||||
|
result["type"] = r.type; result["id"] = r.id; result["parentId"] = r.parentId;
|
||||||
|
result["agentId"] = r.agentId; result["level"] = r.level; result["expanded"] = r.expanded;
|
||||||
|
result["parentExpanded"] = r.parentExpanded; result["state"] = r.state;
|
||||||
|
result["model"] = r.model; result["displayName"] = r.displayName;
|
||||||
|
result["baseName"] = r.baseName; result["connectionColor"] = r.connectionColor;
|
||||||
|
result["paused"] = r.paused;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SessionModel::firstAgentId(const QString &sessionId) const
|
||||||
|
{
|
||||||
|
for (const Row &row : m_rows)
|
||||||
|
if (row.type == "agent" && row.parentId == sessionId) return row.agentId;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionModel::updateAgentState(const QString &sessionId, const QString &agentId, const QString &state)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < m_rows.size(); ++i) {
|
||||||
|
if (m_rows[i].type == "agent" && m_rows[i].parentId == sessionId && m_rows[i].agentId == agentId) {
|
||||||
|
m_rows[i].state = state;
|
||||||
|
emit dataChanged(index(i), index(i), {StateRole});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
#ifndef SESSIONMODEL_H
|
||||||
|
#define SESSIONMODEL_H
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QVariantList>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class Ollie9pClient;
|
||||||
|
|
||||||
|
class SessionModel : public QAbstractListModel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
|
||||||
|
public:
|
||||||
|
enum Roles {
|
||||||
|
TypeRole = Qt::UserRole + 1,
|
||||||
|
IdRole,
|
||||||
|
ParentIdRole,
|
||||||
|
AgentIdRole,
|
||||||
|
LevelRole,
|
||||||
|
ExpandedRole,
|
||||||
|
ParentExpandedRole,
|
||||||
|
StateRole,
|
||||||
|
ModelRole,
|
||||||
|
DisplayNameRole,
|
||||||
|
BaseNameRole,
|
||||||
|
ConnectionColorRole,
|
||||||
|
PausedRole
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit SessionModel(Ollie9pClient *client, QObject *parent = nullptr);
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &parent = {}) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
QHash<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
Q_INVOKABLE QVariantMap get(int index) const;
|
||||||
|
Q_INVOKABLE void setExpanded(int index, bool expanded);
|
||||||
|
Q_INVOKABLE QString firstAgentId(const QString &sessionId) const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void countChanged();
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void rebuild();
|
||||||
|
void updateAgentState(const QString &sessionId, const QString &agentId, const QString &state);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Row {
|
||||||
|
QString type;
|
||||||
|
QString id;
|
||||||
|
QString parentId;
|
||||||
|
QString agentId;
|
||||||
|
int level = 0;
|
||||||
|
bool expanded = true;
|
||||||
|
bool parentExpanded = true;
|
||||||
|
QString state;
|
||||||
|
QString model;
|
||||||
|
QString displayName;
|
||||||
|
QString baseName;
|
||||||
|
QString connectionColor;
|
||||||
|
bool paused = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
static QString connectionColor(const QVariantMap &session);
|
||||||
|
void appendSession(const QVariantMap &session);
|
||||||
|
void updateVisibility();
|
||||||
|
|
||||||
|
Ollie9pClient *m_client;
|
||||||
|
QList<Row> m_rows;
|
||||||
|
QHash<QString, bool> m_expanded;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
Reference in New Issue