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.
This commit is contained in:
Levi Neely 2026-07-16 22:06:08 +02:00
parent 8e3418c4fe
commit 6362e399bd
5 changed files with 204 additions and 1 deletions

View File

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

173
gui/SessionGraph.qml Normal file
View File

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

View File

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

View File

@ -341,6 +341,14 @@ void OllieDBusClient::onPortalFolderResponse(uint response, const QVariantMap &r
}
}
QStringList OllieDBusClient::peerList(const QString &sessionId)
{
QDBusReply<QStringList> reply = m_iface->call("PeerList", sessionId);
if (reply.isValid())
return reply.value();
return {};
}
bool OllieDBusClient::killSession(const QString &sessionId)
{
if (sessionId == m_activeSessionId) {

View File

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