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