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
This commit is contained in:
Levi Neely 2026-08-03 18:30:14 +02:00
parent 6210fccf30
commit 453cef397f
3 changed files with 111 additions and 19 deletions

View File

@ -11,6 +11,9 @@ ApplicationWindow {
font.family: theme.fontFamily || Qt.application.font.family font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize 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 // Apply theme palette to all Qt Quick Controls
palette.active.window: theme.windowColor palette.active.window: theme.windowColor
palette.active.windowText: theme.windowTextColor palette.active.windowText: theme.windowTextColor
@ -130,12 +133,14 @@ ApplicationWindow {
}) })
for (var j = 0; j < agents.length; ++j) { for (var j = 0; j < agents.length; ++j) {
var agent = agents[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({ sessionTreeModel.append({
type: "agent", type: "agent",
parentId: sessionId, parentId: sessionId,
agentId: agent.id, agentId: agent.id,
level: 1, level: 1,
state: "idle", state: liveState || agent.state || "idle",
connected: agent.connected, connected: agent.connected,
displayName: agent.name displayName: agent.name
}) })
@ -205,6 +210,10 @@ ApplicationWindow {
function onSessionsChanged() { function onSessionsChanged() {
buildSessionTree() buildSessionTree()
} }
function onAgentStateChanged(sessionId, agentId, state) {
// Increment version to force re-evaluation of all agent state colors
root.agentStateVersion++
}
} }
Component.onCompleted: { Component.onCompleted: {
@ -779,20 +788,25 @@ ApplicationWindow {
// State dot // State dot
Rectangle { Rectangle {
id: stateDot
Layout.preferredWidth: 8 Layout.preferredWidth: 8
Layout.preferredHeight: 8 Layout.preferredHeight: 8
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
radius: 4 radius: 4
property int stateVersion: root.agentStateVersion
color: { color: {
var s = state || "idle" // Depend on stateVersion to force re-evaluation
if (type === "agent") void(stateVersion)
return connected ? "#4caf50" : "#f44336"
if (type === "session") { if (type === "session") {
if (connectionColor === "green") return "#4caf50" // Session: RPC connection health
if (connectionColor === "yellow") return "#ffca28" var cc = connectionColor || "gray"
if (connectionColor === "red") return "#f44336" if (cc === "green") return "#4caf50"
if (cc === "yellow") return "#ffca28"
if (cc === "red") return "#f44336"
return "#9e9e9e" return "#9e9e9e"
} }
// Agent: execution state - query live state
var s = ollie.agentState(parentId, agentId) || "idle"
if (s === "idle") return "#4caf50" if (s === "idle") return "#4caf50"
if (s.startsWith("calling")) return "#ff9800" if (s.startsWith("calling")) return "#ff9800"
return "#2196f3" return "#2196f3"

View File

@ -84,10 +84,12 @@ Ollie9pClient::Ollie9pClient(QObject *parent)
refreshSessions(); refreshSessions();
ensureRootDataLoaded(); ensureRootDataLoaded();
startAgentConnections(); startAgentConnections();
reconcileAgentStateStreams();
}); });
connect(m_daemon, &NinePConnection::disconnected, this, [this]() { connect(m_daemon, &NinePConnection::disconnected, this, [this]() {
setDaemonConnected(false); setDaemonConnected(false);
stopAgentConnections(); stopAgentConnections();
stopAgentStateStreams();
}); });
connect(m_daemon, &NinePConnection::readyRead, this, [this](const QByteArray &) { connect(m_daemon, &NinePConnection::readyRead, this, [this](const QByteArray &) {
refreshSessions(); refreshSessions();
@ -286,6 +288,70 @@ void Ollie9pClient::stopAgentConnections()
m_agentConnections.clear(); m_agentConnections.clear();
} }
void Ollie9pClient::reconcileAgentStateStreams()
{
if (!m_daemonConnected) return;
QSet<QString> 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() void Ollie9pClient::refreshSessions()
{ {
if (!m_daemonConnected) return; if (!m_daemonConnected) return;
@ -321,22 +387,26 @@ void Ollie9pClient::refreshSessions()
QString connectedStr = QString::fromUtf8(run9p({"read", "session/" + sessionId + "/connected"})).trimmed(); QString connectedStr = QString::fromUtf8(run9p({"read", "session/" + sessionId + "/connected"})).trimmed();
session["connected"] = (connectedStr == "true"); session["connected"] = (connectedStr == "true");
QVariantList agents; QVariantList agents;
for (const QString &name : QString::fromUtf8(run9p({"ls", "session/" + sessionId + "/agent"})).trimmed().split('\n', Qt::SkipEmptyParts)) { for (const QString &name : QString::fromUtf8(run9p({"ls", "session/" + sessionId + "/agent"})).trimmed().split('\n', Qt::SkipEmptyParts)) {
if (name == "new") continue; if (name == "new") continue;
const QString path = "session/" + sessionId + "/agent/" + name; const QString path = "session/" + sessionId + "/agent/" + name;
AgentRecord a; AgentRecord a;
a.id = QString::fromUtf8(run9p({"read", path + "/id"})).trimmed(); a.id = QString::fromUtf8(run9p({"read", path + "/id"})).trimmed();
a.name = name; a.name = name;
if (a.id.isEmpty()) a.id = name; if (a.id.isEmpty()) a.id = name;
QVariantMap av; av["id"] = a.id; av["name"] = a.name; QString agentState = QString::fromUtf8(run9p({"read", path + "/state"})).trimmed();
av["connected"] = m_daemonConnected && m_agentConnected.value(agentKey(sessionId, a.id), false); if (agentState.isEmpty()) agentState = "idle";
agents.append(av); QVariantMap av; av["id"] = a.id; av["name"] = a.name;
} av["state"] = agentState;
session["agents"] = agents; av["connected"] = m_daemonConnected && m_agentConnected.value(agentKey(sessionId, a.id), false);
agents.append(av);
}
session["agents"] = agents;
m_sessions.append(session); m_sessions.append(session);
} }
} }
reconcileAgentConnections(); reconcileAgentConnections();
reconcileAgentStateStreams();
emit sessionsChanged(); emit sessionsChanged();
// Check if the currently active session/agent still exist // Check if the currently active session/agent still exist

View File

@ -52,6 +52,7 @@ public:
QString currentBackend() const { return m_currentBackend; } QString currentBackend() const { return m_currentBackend; }
bool daemonConnected() const { return m_daemonConnected; } bool daemonConnected() const { return m_daemonConnected; }
Q_INVOKABLE bool agentConnected(const QString &sessionId, const QString &agentId) const; 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 QString sessionConnectionColor(const QString &sessionId) const;
Q_INVOKABLE void setCurrentBackend(const QString &backend) { Q_INVOKABLE void setCurrentBackend(const QString &backend) {
if (m_currentBackend == backend) return; if (m_currentBackend == backend) return;
@ -94,6 +95,7 @@ signals:
void activeSessionIdChanged(); void activeSessionIdChanged();
void activeAgentIdChanged(); void activeAgentIdChanged();
void activeStateChanged(); void activeStateChanged();
void agentStateChanged(const QString &sessionId, const QString &agentId, const QString &state);
void chatReceived(const QString &text); void chatReceived(const QString &text);
void sessionConnected(); void sessionConnected();
void streamingDone(); // chat process exited (session killed) void streamingDone(); // chat process exited (session killed)
@ -115,7 +117,9 @@ private:
void startAgentConnections(); void startAgentConnections();
void startActiveAgentStreams(); void startActiveAgentStreams();
void reconcileAgentConnections(); void reconcileAgentConnections();
void reconcileAgentStateStreams();
void stopAgentConnections(); void stopAgentConnections();
void stopAgentStateStreams();
QVariantList m_sessions; QVariantList m_sessions;
QString m_activeSessionId; QString m_activeSessionId;
@ -136,6 +140,10 @@ private:
QSet<QString> m_seenAgentConnections; QSet<QString> m_seenAgentConnections;
NinePConnection *m_daemon = nullptr; NinePConnection *m_daemon = nullptr;
// Per-agent state tracking
QHash<QString, StreamFsm *> m_agentStateStreams;
QHash<QString, QString> m_agentStateValues;
// Streaming FSMs — each wraps a QProcess lifecycle // Streaming FSMs — each wraps a QProcess lifecycle
StreamFsm *m_chat = nullptr; // Oneshot — reads agent chat log StreamFsm *m_chat = nullptr; // Oneshot — reads agent chat log
StreamFsm *m_state = nullptr; // Guarded — reads agent statewait StreamFsm *m_state = nullptr; // Guarded — reads agent statewait