From 541ef48bb7254ab84b0affaddc3d9c174ef68c05 Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:56:49 +0200 Subject: [PATCH] Refactor GUI into session tree and chat pane components --- CMakeLists.txt | 6 + gui/ChatPane.qml | 268 +++++++++++++ gui/SessionTree.qml | 281 +++++++++++++ gui/main.cpp | 11 +- gui/main.qml | 912 +----------------------------------------- gui/ollie9pclient.cpp | 16 + gui/qml.qrc | 2 + gui/sessionmodel.cpp | 160 ++++++++ gui/sessionmodel.h | 76 ++++ 9 files changed, 835 insertions(+), 897 deletions(-) create mode 100644 gui/ChatPane.qml create mode 100644 gui/SessionTree.qml create mode 100644 gui/sessionmodel.cpp create mode 100644 gui/sessionmodel.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 41b41ce..9be0ff6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -110,11 +110,14 @@ if(OLLIE_KF5) 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/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) qt5_add_resources(GUI_KF5_QRC ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/qml.qrc) add_executable(ollie-gui gui/main.cpp gui/ollie9pclient.cpp + gui/sessionmodel.cpp gui/lib9pclient.cpp gui/nativestreamer.cpp gui/chatblockmodel.cpp @@ -203,6 +206,7 @@ else() add_executable(ollie-gui gui/main.cpp gui/ollie9pclient.cpp + gui/sessionmodel.cpp gui/lib9pclient.cpp gui/nativestreamer.cpp gui/chatblockmodel.cpp @@ -232,6 +236,8 @@ else() gui/NewSessionDialog.qml gui/NewAgentDialog.qml gui/ChatBlockDelegate.qml + gui/SessionTree.qml + gui/ChatPane.qml ) # --- Install --- diff --git a/gui/ChatPane.qml b/gui/ChatPane.qml new file mode 100644 index 0000000..15803dd --- /dev/null +++ b/gui/ChatPane.qml @@ -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() } +} + +} diff --git a/gui/SessionTree.qml b/gui/SessionTree.qml new file mode 100644 index 0000000..30ffd1e --- /dev/null +++ b/gui/SessionTree.qml @@ -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 = "" } + +} diff --git a/gui/main.cpp b/gui/main.cpp index d5f857f..cd352d2 100644 --- a/gui/main.cpp +++ b/gui/main.cpp @@ -6,6 +6,7 @@ #include #include "ollie9pclient.h" +#include "sessionmodel.h" #include "chatblockmodel.h" #include "thememanager.h" #include "clipboardhelper.h" @@ -26,17 +27,17 @@ int main(int argc, char *argv[]) QQmlApplicationEngine engine; auto *ollie = new Ollie9pClient(&app); + auto *sessionModel = new SessionModel(ollie, &app); auto *chatModel = new ChatBlockModel(&app); auto *theme = new ThemeManager(&app); auto *clipboard = new ClipboardHelper(&app); - // Route chat stream to the block model. - // Reset + reload is handled in QML's onActiveSessionIdChanged so the - // 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); + QObject::connect(ollie, &Ollie9pClient::chatReceived, + chatModel, &ChatBlockModel::appendData); + engine.rootContext()->setContextProperty("ollie", ollie); + engine.rootContext()->setContextProperty("sessionModel", sessionModel); engine.rootContext()->setContextProperty("chatModel", chatModel); engine.rootContext()->setContextProperty("theme", theme); engine.rootContext()->setContextProperty("clipboard", clipboard); diff --git a/gui/main.qml b/gui/main.qml index 118c12d..c68ae90 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -67,8 +67,8 @@ ApplicationWindow { Shortcut { sequence: "Escape" onActivated: { - if (renamingIndex >= 0) { - cancelRename() + if (sessionTree.renamingIndex >= 0) { + sessionTree.cancelRename() } else { // Always send stop - server-side Interrupt() is safe to call // on an idle agent (no-op). This avoids races where the GUI's @@ -78,415 +78,8 @@ ApplicationWindow { } } - // Sidebar visibility 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 --- Dialog { id: renameErrorDialog @@ -518,500 +111,35 @@ ApplicationWindow { // --- New Agent Dialog --- NewAgentDialog { id: newAgentDialog - targetSessionId: root.newAgentTargetSessionId + targetSessionId: "" } SplitView { anchors.fill: parent - // Left pane: session list - ColumnLayout { - id: sidebarPane + SessionTree { + id: sessionTree SplitView.preferredWidth: root.sidebarVisible ? root.width * 0.15 : 0 SplitView.minimumWidth: root.sidebarVisible ? 300 : 0 - SplitView.maximumWidth: root.sidebarVisible ? Infinity : 0 - visible: root.sidebarVisible - 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: 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 - } - } + visiblePane: root.sidebarVisible + onNewSessionRequested: newSessionDialog.open() + onSettingsRequested: settingsDialog.open() + onNewAgentRequested: function(sessionId) { + newAgentDialog.targetSessionId = sessionId + newAgentDialog.open() } - - ListView { - id: sessionList - Layout.fillWidth: true - 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) - } + onRenameFailed: function(itemType, attemptedName) { + renameErrorDialog.itemType = itemType + renameErrorDialog.attemptedName = attemptedName + renameErrorDialog.open() } } - // Right pane: chat + input - ColumnLayout { - spacing: 0 - - // Header - shows active session/agent and state - 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() - } - } - } - } + ChatPane { + SplitView.fillWidth: true + SplitView.fillHeight: true + sidebarVisible: root.sidebarVisible + onToggleSidebar: root.sidebarVisible = !root.sidebarVisible } } } diff --git a/gui/ollie9pclient.cpp b/gui/ollie9pclient.cpp index 78f0d5a..d553557 100644 --- a/gui/ollie9pclient.cpp +++ b/gui/ollie9pclient.cpp @@ -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 if (m_activeSessionId.isEmpty() && !m_sessions.isEmpty()) { const QVariantMap firstSession = m_sessions.first().toMap(); diff --git a/gui/qml.qrc b/gui/qml.qrc index fc3fe43..a491fee 100644 --- a/gui/qml.qrc +++ b/gui/qml.qrc @@ -5,5 +5,7 @@ NewSessionDialog.qml NewAgentDialog.qml ChatBlockDelegate.qml + SessionTree.qml + ChatPane.qml diff --git a/gui/sessionmodel.cpp b/gui/sessionmodel.cpp new file mode 100644 index 0000000..7480926 --- /dev/null +++ b/gui/sessionmodel.cpp @@ -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 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 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; + } + } +} diff --git a/gui/sessionmodel.h b/gui/sessionmodel.h new file mode 100644 index 0000000..374168c --- /dev/null +++ b/gui/sessionmodel.h @@ -0,0 +1,76 @@ +#ifndef SESSIONMODEL_H +#define SESSIONMODEL_H + +#include +#include +#include +#include +#include + +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 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 m_rows; + QHash m_expanded; +}; + +#endif