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