From 453cef397f67d373671e08fa13c1fad3a87cccc0 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Mon, 3 Aug 2026 18:30:14 +0200 Subject: [PATCH] live agent state indicators in session tree - Add per-agent statewait streams to track execution state - Agent dots show: green=idle, orange=calling, blue=thinking - Session dots show RPC connection health (unchanged) - Use version counter pattern to force QML binding re-evaluation --- gui/main.qml | 28 +++++++++---- gui/ollie9pclient.cpp | 94 +++++++++++++++++++++++++++++++++++++------ gui/ollie9pclient.h | 8 ++++ 3 files changed, 111 insertions(+), 19 deletions(-) diff --git a/gui/main.qml b/gui/main.qml index a8528d0..db52f34 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -11,6 +11,9 @@ ApplicationWindow { font.family: theme.fontFamily || Qt.application.font.family font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize + // Version counter to force re-evaluation of agent state colors + property int agentStateVersion: 0 + // Apply theme palette to all Qt Quick Controls palette.active.window: theme.windowColor palette.active.windowText: theme.windowTextColor @@ -130,12 +133,14 @@ ApplicationWindow { }) for (var j = 0; j < agents.length; ++j) { var agent = agents[j] + // Use live state if available, otherwise fall back to refresh-time state + var liveState = ollie.agentState(sessionId, agent.id) sessionTreeModel.append({ type: "agent", parentId: sessionId, agentId: agent.id, level: 1, - state: "idle", + state: liveState || agent.state || "idle", connected: agent.connected, displayName: agent.name }) @@ -205,6 +210,10 @@ ApplicationWindow { function onSessionsChanged() { buildSessionTree() } + function onAgentStateChanged(sessionId, agentId, state) { + // Increment version to force re-evaluation of all agent state colors + root.agentStateVersion++ + } } Component.onCompleted: { @@ -779,20 +788,25 @@ ApplicationWindow { // State dot Rectangle { + id: stateDot Layout.preferredWidth: 8 Layout.preferredHeight: 8 Layout.alignment: Qt.AlignVCenter radius: 4 + property int stateVersion: root.agentStateVersion color: { - var s = state || "idle" - if (type === "agent") - return connected ? "#4caf50" : "#f44336" + // Depend on stateVersion to force re-evaluation + void(stateVersion) if (type === "session") { - if (connectionColor === "green") return "#4caf50" - if (connectionColor === "yellow") return "#ffca28" - if (connectionColor === "red") return "#f44336" + // Session: RPC connection health + var cc = connectionColor || "gray" + if (cc === "green") return "#4caf50" + if (cc === "yellow") return "#ffca28" + if (cc === "red") return "#f44336" return "#9e9e9e" } + // Agent: execution state - query live state + var s = ollie.agentState(parentId, agentId) || "idle" if (s === "idle") return "#4caf50" if (s.startsWith("calling")) return "#ff9800" return "#2196f3" diff --git a/gui/ollie9pclient.cpp b/gui/ollie9pclient.cpp index 08979d0..7e2cbeb 100644 --- a/gui/ollie9pclient.cpp +++ b/gui/ollie9pclient.cpp @@ -84,10 +84,12 @@ Ollie9pClient::Ollie9pClient(QObject *parent) refreshSessions(); ensureRootDataLoaded(); startAgentConnections(); + reconcileAgentStateStreams(); }); connect(m_daemon, &NinePConnection::disconnected, this, [this]() { setDaemonConnected(false); stopAgentConnections(); + stopAgentStateStreams(); }); connect(m_daemon, &NinePConnection::readyRead, this, [this](const QByteArray &) { refreshSessions(); @@ -286,6 +288,70 @@ void Ollie9pClient::stopAgentConnections() m_agentConnections.clear(); } +void Ollie9pClient::reconcileAgentStateStreams() +{ + if (!m_daemonConnected) return; + QSet wanted; + for (const QVariant &value : std::as_const(m_sessions)) { + const QVariantMap session = value.toMap(); + const QString sid = session.value("id").toString(); + const bool paused = session.value("paused").toBool(); + for (const QVariant &agentValue : session.value("agents").toList()) { + const QVariantMap agent = agentValue.toMap(); + const QString aid = agent.value("id").toString(); + const QString key = agentKey(sid, aid); + wanted.insert(key); + if (m_agentStateStreams.contains(key)) continue; + if (paused) continue; // Don't start streams for paused sessions + auto *stream = new StreamFsm(StreamFsm::Looping, this); + m_agentStateStreams.insert(key, stream); + m_agentStateValues.insert(key, "idle"); + connect(stream, &StreamFsm::readyRead, this, [this, key, sid, aid](const QByteArray &data) { + QString state = QString::fromUtf8(data).trimmed(); + if (!state.isEmpty() && m_agentStateValues.value(key) != state) { + m_agentStateValues[key] = state; + emit agentStateChanged(sid, aid, state); + // Also update activeState if this is the active agent + if (sid == m_activeSessionId && aid == m_agentId) { + m_activeState = state; + emit activeStateChanged(); + } + } + }); + stream->start(ollie9pBin(), {"-a", serverAddr(), "read", + "session/" + sid + "/agent/" + aid + "/statewait"}); + } + } + // Remove streams for agents that no longer exist + for (auto it = m_agentStateStreams.begin(); it != m_agentStateStreams.end();) { + if (wanted.contains(it.key())) { + ++it; + continue; + } + it.value()->disconnect(this); + it.value()->stop(); + it.value()->deleteLater(); + m_agentStateValues.remove(it.key()); + it = m_agentStateStreams.erase(it); + } +} + +void Ollie9pClient::stopAgentStateStreams() +{ + for (StreamFsm *stream : std::as_const(m_agentStateStreams)) { + stream->disconnect(this); + stream->stop(); + } + qDeleteAll(m_agentStateStreams); + m_agentStateStreams.clear(); + m_agentStateValues.clear(); +} + +QString Ollie9pClient::agentState(const QString &sessionId, const QString &agentId) const +{ + return m_agentStateValues.value(agentKey(sessionId, agentId), "idle"); +} + void Ollie9pClient::refreshSessions() { if (!m_daemonConnected) return; @@ -321,22 +387,26 @@ void Ollie9pClient::refreshSessions() QString connectedStr = QString::fromUtf8(run9p({"read", "session/" + sessionId + "/connected"})).trimmed(); session["connected"] = (connectedStr == "true"); QVariantList agents; - for (const QString &name : QString::fromUtf8(run9p({"ls", "session/" + sessionId + "/agent"})).trimmed().split('\n', Qt::SkipEmptyParts)) { - if (name == "new") continue; - const QString path = "session/" + sessionId + "/agent/" + name; - AgentRecord a; - a.id = QString::fromUtf8(run9p({"read", path + "/id"})).trimmed(); - a.name = name; - if (a.id.isEmpty()) a.id = name; - QVariantMap av; av["id"] = a.id; av["name"] = a.name; - av["connected"] = m_daemonConnected && m_agentConnected.value(agentKey(sessionId, a.id), false); - agents.append(av); - } - session["agents"] = agents; + for (const QString &name : QString::fromUtf8(run9p({"ls", "session/" + sessionId + "/agent"})).trimmed().split('\n', Qt::SkipEmptyParts)) { + if (name == "new") continue; + const QString path = "session/" + sessionId + "/agent/" + name; + AgentRecord a; + a.id = QString::fromUtf8(run9p({"read", path + "/id"})).trimmed(); + a.name = name; + if (a.id.isEmpty()) a.id = name; + QString agentState = QString::fromUtf8(run9p({"read", path + "/state"})).trimmed(); + if (agentState.isEmpty()) agentState = "idle"; + QVariantMap av; av["id"] = a.id; av["name"] = a.name; + av["state"] = agentState; + av["connected"] = m_daemonConnected && m_agentConnected.value(agentKey(sessionId, a.id), false); + agents.append(av); + } + session["agents"] = agents; m_sessions.append(session); } } reconcileAgentConnections(); + reconcileAgentStateStreams(); emit sessionsChanged(); // Check if the currently active session/agent still exist diff --git a/gui/ollie9pclient.h b/gui/ollie9pclient.h index 4c68d60..79c6adb 100644 --- a/gui/ollie9pclient.h +++ b/gui/ollie9pclient.h @@ -52,6 +52,7 @@ public: QString currentBackend() const { return m_currentBackend; } bool daemonConnected() const { return m_daemonConnected; } Q_INVOKABLE bool agentConnected(const QString &sessionId, const QString &agentId) const; + Q_INVOKABLE QString agentState(const QString &sessionId, const QString &agentId) const; Q_INVOKABLE QString sessionConnectionColor(const QString &sessionId) const; Q_INVOKABLE void setCurrentBackend(const QString &backend) { if (m_currentBackend == backend) return; @@ -94,6 +95,7 @@ signals: void activeSessionIdChanged(); void activeAgentIdChanged(); void activeStateChanged(); + void agentStateChanged(const QString &sessionId, const QString &agentId, const QString &state); void chatReceived(const QString &text); void sessionConnected(); void streamingDone(); // chat process exited (session killed) @@ -115,7 +117,9 @@ private: void startAgentConnections(); void startActiveAgentStreams(); void reconcileAgentConnections(); + void reconcileAgentStateStreams(); void stopAgentConnections(); + void stopAgentStateStreams(); QVariantList m_sessions; QString m_activeSessionId; @@ -136,6 +140,10 @@ private: QSet m_seenAgentConnections; NinePConnection *m_daemon = nullptr; + // Per-agent state tracking + QHash m_agentStateStreams; + QHash m_agentStateValues; + // Streaming FSMs — each wraps a QProcess lifecycle StreamFsm *m_chat = nullptr; // Oneshot — reads agent chat log StreamFsm *m_state = nullptr; // Guarded — reads agent statewait