From 6362e399bda6a3f68a6a7ca1b899e7c4fdebf19b Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:06:08 +0200 Subject: [PATCH] gui: session topology graph Circular node layout showing all sessions with peer relationship edges. Nodes are color-coded by state (green=idle, blue=thinking, orange=calling), pulse when active. Click a node to select that session. Toggle between list and graph view in sidebar toolbar. Adds peerList() to OllieDBusClient. --- CMakeLists.txt | 2 +- gui/SessionGraph.qml | 173 ++++++++++++++++++++++++++++ gui/main.qml | 20 ++++ plasmoid/plugin/olliedbusclient.cpp | 8 ++ plasmoid/plugin/olliedbusclient.h | 2 + 5 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 gui/SessionGraph.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index 305caa8..177dfeb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -247,7 +247,7 @@ else() ) qt_add_resources(ollie-gui "gui" PREFIX "/" - FILES gui/main.qml gui/emojis.js + FILES gui/main.qml gui/SessionGraph.qml gui/emojis.js ) # --- Install --- diff --git a/gui/SessionGraph.qml b/gui/SessionGraph.qml new file mode 100644 index 0000000..cba4e36 --- /dev/null +++ b/gui/SessionGraph.qml @@ -0,0 +1,173 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Session topology graph: nodes = sessions, edges = peer relationships +Item { + id: graphRoot + + property var sessions: [] // [{id, state, model, agent}] + property var peerListFn: null // function(sessionId) => [peerId, ...] + + // Layout: arrange nodes in a circle + property var nodePositions: ({}) + + onSessionsChanged: layoutNodes() + onWidthChanged: layoutNodes() + onHeightChanged: layoutNodes() + + function layoutNodes() { + let positions = {} + let count = sessions.length + if (count === 0) return + + let cx = width / 2 + let cy = height / 2 + let radius = Math.min(width, height) * 0.35 + + // Single node goes in center + if (count === 1) { + positions[sessions[0].id] = {x: cx, y: cy} + } else { + for (let i = 0; i < count; i++) { + let angle = (2 * Math.PI * i / count) - Math.PI / 2 + positions[sessions[i].id] = { + x: cx + radius * Math.cos(angle), + y: cy + radius * Math.sin(angle) + } + } + } + nodePositions = positions + canvas.requestPaint() + } + + function stateColor(state) { + if (state === "idle") return "#4caf50" + if (state && state.startsWith("calling")) return "#ff9800" + if (state === "thinking") return "#2196f3" + return "#2196f3" + } + + // Edge canvas + Canvas { + id: canvas + anchors.fill: parent + + onPaint: { + let ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.4) + ctx.lineWidth = 2 + + if (!peerListFn) return + + for (let i = 0; i < sessions.length; i++) { + let sid = sessions[i].id + let peers = peerListFn(sid) + let fromPos = nodePositions[sid] + if (!fromPos) continue + + for (let j = 0; j < peers.length; j++) { + let toPos = nodePositions[peers[j]] + if (!toPos) continue + + ctx.beginPath() + ctx.moveTo(fromPos.x, fromPos.y) + ctx.lineTo(toPos.x, toPos.y) + ctx.stroke() + + // Arrow head + let dx = toPos.x - fromPos.x + let dy = toPos.y - fromPos.y + let len = Math.sqrt(dx * dx + dy * dy) + if (len < 1) continue + let ux = dx / len + let uy = dy / len + // Arrow at 30px from target (node radius) + let ax = toPos.x - ux * 30 + let ay = toPos.y - uy * 30 + let arrowSize = 8 + ctx.beginPath() + ctx.moveTo(ax, ay) + ctx.lineTo(ax - arrowSize * ux + arrowSize * 0.5 * uy, + ay - arrowSize * uy - arrowSize * 0.5 * ux) + ctx.lineTo(ax - arrowSize * ux - arrowSize * 0.5 * uy, + ay - arrowSize * uy + arrowSize * 0.5 * ux) + ctx.closePath() + ctx.fillStyle = Qt.rgba(0.5, 0.5, 0.5, 0.6) + ctx.fill() + } + } + } + } + + // Session nodes + Repeater { + model: graphRoot.sessions + + Rectangle { + id: nodeRect + width: 120 + height: 50 + radius: 8 + color: palette.base + border.color: graphRoot.stateColor(modelData.state) + border.width: modelData.id === dbus.activeSessionId ? 3 : 2 + + x: { + let pos = graphRoot.nodePositions[modelData.id] + return pos ? pos.x - width / 2 : 0 + } + y: { + let pos = graphRoot.nodePositions[modelData.id] + return pos ? pos.y - height / 2 : 0 + } + + // Pulse animation for active sessions + SequentialAnimation on opacity { + running: modelData.state !== "idle" + loops: Animation.Infinite + NumberAnimation { to: 0.7; duration: 600; easing.type: Easing.InOutSine } + NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutSine } + } + opacity: modelData.state === "idle" ? 1.0 : opacity + + Column { + anchors.centerIn: parent + spacing: 2 + + Label { + text: modelData.id + font.bold: true + font.pixelSize: 11 + anchors.horizontalCenter: parent.horizontalCenter + elide: Text.ElideRight + width: nodeRect.width - 12 + horizontalAlignment: Text.AlignHCenter + } + + Label { + text: modelData.state || "idle" + font.pixelSize: 9 + opacity: 0.7 + anchors.horizontalCenter: parent.horizontalCenter + color: graphRoot.stateColor(modelData.state) + } + } + + MouseArea { + anchors.fill: parent + onClicked: dbus.activeSessionId = modelData.id + cursorShape: Qt.PointingHandCursor + } + } + } + + // Empty state + Label { + anchors.centerIn: parent + text: "No active sessions" + opacity: 0.5 + visible: sessions.length === 0 + } +} diff --git a/gui/main.qml b/gui/main.qml index cc1fd6a..c18d816 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -229,6 +229,25 @@ ApplicationWindow { ToolTip.text: "Settings" ToolTip.visible: hovered } + + ToolButton { + icon.name: checked ? "view-list-text" : "network-workgroup" + checkable: true + checked: false + onToggled: graphView.visible = checked + ToolTip.text: checked ? "Show session list" : "Show session graph" + ToolTip.visible: hovered + } + } + + // Session graph (topology view) + SessionGraph { + id: graphView + Layout.fillWidth: true + Layout.fillHeight: true + visible: false + sessions: dbus.sessions + peerListFn: function(sid) { return dbus.peerList(sid) } } // Session list @@ -236,6 +255,7 @@ ApplicationWindow { id: sessionList Layout.fillWidth: true Layout.fillHeight: true + visible: !graphView.visible clip: true model: dbus.sessions diff --git a/plasmoid/plugin/olliedbusclient.cpp b/plasmoid/plugin/olliedbusclient.cpp index 23702e2..419f222 100644 --- a/plasmoid/plugin/olliedbusclient.cpp +++ b/plasmoid/plugin/olliedbusclient.cpp @@ -341,6 +341,14 @@ void OllieDBusClient::onPortalFolderResponse(uint response, const QVariantMap &r } } +QStringList OllieDBusClient::peerList(const QString &sessionId) +{ + QDBusReply reply = m_iface->call("PeerList", sessionId); + if (reply.isValid()) + return reply.value(); + return {}; +} + bool OllieDBusClient::killSession(const QString &sessionId) { if (sessionId == m_activeSessionId) { diff --git a/plasmoid/plugin/olliedbusclient.h b/plasmoid/plugin/olliedbusclient.h index f718aef..ba24e92 100644 --- a/plasmoid/plugin/olliedbusclient.h +++ b/plasmoid/plugin/olliedbusclient.h @@ -32,6 +32,8 @@ public: explicit OllieDBusClient(QObject *parent = nullptr); QVariantList sessions() const { return m_sessions; } + + Q_INVOKABLE QStringList peerList(const QString &sessionId); QString activeSessionId() const { return m_activeSessionId; } void setActiveSessionId(const QString &id); QString chatText() const { return m_chatText; }