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.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"

View File

@ -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<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()
{
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

View File

@ -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<QString> m_seenAgentConnections;
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
StreamFsm *m_chat = nullptr; // Oneshot — reads agent chat log
StreamFsm *m_state = nullptr; // Guarded — reads agent statewait