This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-kde/gui/SessionGraph.qml

226 lines
7.0 KiB
QML

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: ({})
// Pan and zoom state
property real panX: 0
property real panY: 0
property real zoom: 1.0
property real minZoom: 0.3
property real maxZoom: 3.0
clip: true
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"
}
// Pan/zoom mouse handling
MouseArea {
id: panArea
anchors.fill: parent
property real lastX: 0
property real lastY: 0
onPressed: function(mouse) {
lastX = mouse.x
lastY = mouse.y
}
onPositionChanged: function(mouse) {
if (pressed) {
graphRoot.panX += (mouse.x - lastX)
graphRoot.panY += (mouse.y - lastY)
lastX = mouse.x
lastY = mouse.y
}
}
onWheel: function(wheel) {
let factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87
let newZoom = Math.max(graphRoot.minZoom, Math.min(graphRoot.maxZoom, graphRoot.zoom * factor))
// Zoom toward mouse position
let mx = wheel.x - graphRoot.panX
let my = wheel.y - graphRoot.panY
graphRoot.panX -= mx * (newZoom / graphRoot.zoom - 1)
graphRoot.panY -= my * (newZoom / graphRoot.zoom - 1)
graphRoot.zoom = newZoom
}
}
// Transformed content (pan + zoom)
Item {
id: graphContent
x: graphRoot.panX
y: graphRoot.panY
width: parent.width
height: parent.height
scale: graphRoot.zoom
transformOrigin: Item.TopLeft
// 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
}
}
}
} // end graphContent Item
// Empty state
Label {
anchors.centerIn: parent
text: "No active sessions"
opacity: 0.5
visible: sessions.length === 0
}
}