Refactor GUI into session tree and chat pane components

This commit is contained in:
Levi Neely 2026-08-16 12:56:49 +02:00
parent 0e163f6ee6
commit 541ef48bb7
9 changed files with 835 additions and 897 deletions

View File

@ -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 ---

268
gui/ChatPane.qml Normal file
View File

@ -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() }
}
}

281
gui/SessionTree.qml Normal file
View File

@ -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 = "" }
}

View File

@ -6,6 +6,7 @@
#include <QQuickStyle>
#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);

View File

@ -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
}
}
}
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
visiblePane: root.sidebarVisible
onNewSessionRequested: newSessionDialog.open()
onSettingsRequested: settingsDialog.open()
onNewAgentRequested: function(sessionId) {
newAgentDialog.targetSessionId = sessionId
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()
}
onRenameFailed: function(itemType, attemptedName) {
renameErrorDialog.itemType = itemType
renameErrorDialog.attemptedName = attemptedName
renameErrorDialog.open()
}
}
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
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
}
}
}

View File

@ -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();

View File

@ -5,5 +5,7 @@
<file>NewSessionDialog.qml</file>
<file>NewAgentDialog.qml</file>
<file>ChatBlockDelegate.qml</file>
<file>SessionTree.qml</file>
<file>ChatPane.qml</file>
</qresource>
</RCC>

160
gui/sessionmodel.cpp Normal file
View File

@ -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;
}
}
}

76
gui/sessionmodel.h Normal file
View File

@ -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