agent ui: open-threads section (questions + approvals pending)

new /api/open-threads endpoint on hive-ag3nt proxies the agent's
own GetOpenThreads RPC (manager flavour proxies the hive-wide
ManagerRequest::GetOpenThreads). same data the
mcp__hyperhive__get_open_threads tool sees from inside claude.

frontend renders a collapsible <details> section above the
terminal, listing each pending row (approval / question) with
asker → target, age, and free-form body. auto-expands on the
first appearance of any open thread; sticky after that.
refreshed on cold load + after every turn_end (turns are when
threads land or resolve).
This commit is contained in:
müde 2026-05-17 23:53:40 +02:00
parent 087a5366fb
commit 378e8bf9df
3 changed files with 124 additions and 0 deletions

View file

@ -105,6 +105,7 @@ pub async fn serve(
.route("/api/compact", post(post_compact))
.route("/api/model", post(post_set_model))
.route("/api/new-session", post(post_new_session))
.route("/api/open-threads", get(api_open_threads))
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = bind_with_retry(addr, "web UI").await?;
@ -231,6 +232,49 @@ struct SessionView {
exit_note: Option<String>,
}
/// Proxy this agent's open-threads via the per-agent socket. The
/// web UI surfaces the result as a collapsible section in the page
/// so the operator can see at a glance what's pending against the
/// agent (questions asked by it, peer questions targeting it,
/// approvals for the manager). Same data the
/// `mcp__hyperhive__get_open_threads` tool sees from inside the
/// container.
async fn api_open_threads(State(state): State<AppState>) -> Response {
let threads: Vec<hive_sh4re::OpenThread> = match state.flavor() {
Flavor::Agent => {
match client::request::<_, hive_sh4re::AgentResponse>(
&state.socket,
&hive_sh4re::AgentRequest::GetOpenThreads,
)
.await
{
Ok(hive_sh4re::AgentResponse::OpenThreads { threads }) => threads,
Ok(hive_sh4re::AgentResponse::Err { message }) => {
return error_response(&format!("get_open_threads: {message}"));
}
Ok(other) => return error_response(&format!("unexpected response: {other:?}")),
Err(e) => return error_response(&format!("transport: {e:#}")),
}
}
Flavor::Manager => {
match client::request::<_, hive_sh4re::ManagerResponse>(
&state.socket,
&hive_sh4re::ManagerRequest::GetOpenThreads,
)
.await
{
Ok(hive_sh4re::ManagerResponse::OpenThreads { threads }) => threads,
Ok(hive_sh4re::ManagerResponse::Err { message }) => {
return error_response(&format!("get_open_threads: {message}"));
}
Ok(other) => return error_response(&format!("unexpected response: {other:?}")),
Err(e) => return error_response(&format!("transport: {e:#}")),
}
}
};
axum::Json(serde_json::json!({ "threads": threads })).into_response()
}
async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
// Capture seq *before* any reads so the dedupe contract is
// "events with seq > snapshot.seq are post-snapshot, never missed."