feat(android): per-profile session drawer via gateway session.list

Sessions are profile-bound (each in its profile's state.db), but the drawer
listed via the api_server /api/sessions, which reads ONE shared DB with no
profile concept (verified upstream: _handle_list_sessions takes only
limit/offset/source). So the drawer couldn't scope to a profile.

Match the desktop: add GatewayChatClient.listSessions() → the `session.list`
RPC (the call the desktop session picker uses), which reads the active
profile's own DB and so returns only that profile's sessions. refreshSessions()
now routes through it on gateway connections (api_server /api/sessions stays the
SSE / fallback path), so the drawer re-scopes to the active profile's
conversations and switching a profile shows that agent's sessions.

Test: listSessions parses the gateway session list into SessionItems.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bailey Dixon
2026-06-14 00:12:34 -04:00
co-authored by Claude Opus 4.8
parent b7e5c67714
commit a1a758d011
4 changed files with 68 additions and 4 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Agent sheet — collapsible Profile / Personality / Model pickers.** The three lists in the agent sheet are now space-saving dropdowns: each collapses to a header showing the current selection (e.g. "Personality — Catgirl") and expands on tap to choose, instead of rendering every option at once. The rich rows (SOUL/skills badges, provider-grouped models) are unchanged — they just live behind the header now.
- **Switch Hermes agent profiles from chat.** The Profile picker in the agent sheet now lists the host's actual agent profiles (loaded from the dashboard `/api/profiles`, like Manage and the desktop) and switching one switches the *whole agent* — its model, SOUL, personality, and skills. Verified against upstream `tui_gateway`: a profile is a full agent context, sessions are **profile-bound** (the agent is built at `session.create` from the session's profile and a live session never adopts a new one), and the desktop passes `profile` on `session.create` / `session.resume`. So the gateway client now carries the selected profile on session create/resume, and switching starts a fresh session under the new profile — the agent actually becomes it, instead of the header reading "Gary" while the running agent still answered "Victor". (SSE turns already carry the profile per-request as `profileName`.)
- **Switch Hermes agent profiles from chat.** The Profile picker in the agent sheet now lists the host's actual agent profiles (loaded from the dashboard `/api/profiles`, like Manage and the desktop) and switching one switches the *whole agent* — its model, SOUL, personality, and skills. Verified against upstream `tui_gateway`: a profile is a full agent context, sessions are **profile-bound** (the agent is built at `session.create` from the session's profile and a live session never adopts a new one), and the desktop passes `profile` on `session.create` / `session.resume`. So the gateway client now carries the selected profile on session create/resume, and switching starts a fresh session under the new profile — the agent actually becomes it, instead of the header reading "Gary" while the running agent still answered "Victor". (SSE turns already carry the profile per-request as `profileName`.) The session drawer is now **per-profile** on the gateway too: it lists via the gateway `session.list` (which reads the active profile's own DB, like the desktop) instead of the api_server `/api/sessions` shared DB, so switching a profile re-scopes the conversation list to that agent's sessions.
- **Gateway chat transport — live thinking (ADR pending).** Chat can now ride the upstream dashboard's `/api/ws` WebSocket (the `tui_gateway` JSON-RPC surface the official hermes-desktop client speaks) instead of the API-server SSE paths. The gateway is the only vanilla-upstream surface that streams reasoning live (`reasoning.delta`/`thinking.delta`), so the existing Thinking block + sphere thinking state finally light up *during* generation instead of staying dark through a reasoning model's 50–70s think phase. "Auto" endpoint resolution prefers the gateway when the dashboard is reachable and Manage is signed in; every failure (sign-in expired, old server build, attachments, socket loss before submit) falls back to the SSE endpoints per turn. New Chat Settings endpoint option **Gateway** with a sign-in hint; interactive server asks (approval/clarify/sudo/secret) surface as a system notice — answering them from the phone is a follow-up. Gateway sessions persist into the same session DB the drawer lists via `/api/sessions`.
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.network
import android.util.Log
import com.hermesandroid.relay.network.models.SessionItem
import com.hermesandroid.relay.util.AppForegroundTracker
import com.hermesandroid.relay.util.TurnLatencyTracer
import kotlinx.coroutines.CompletableDeferred
@@ -24,6 +25,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
@@ -602,6 +604,38 @@ class GatewayChatClient(
}
}
/**
* List the active profile's sessions via the gateway `session.list` RPC —
* the same call the desktop session picker uses. Sessions are profile-bound
* (each in its profile's state.db), so this returns only the current
* profile's sessions and the drawer re-scopes automatically on a profile
* switch — unlike the api_server `/api/sessions`, which reads one shared DB
* with no profile concept. Mapped into the shared [SessionItem] shape the
* drawer already renders (model/token fields aren't in the gateway item).
*/
suspend fun listSessions(limit: Int = 50): Result<List<SessionItem>> {
if (webSocket == null || readySignal?.isCompleted != true) {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
}
return rpc("session.list", buildJsonObject { put("limit", limit) }).map { result ->
(result["sessions"] as? JsonArray).orEmpty().mapNotNull { el ->
val obj = el as? JsonObject ?: return@mapNotNull null
val id = obj.stringField("id") ?: return@mapNotNull null
SessionItem(
id = id,
title = obj.stringField("title"),
source = obj.stringField("source"),
startedAt = (obj["started_at"] as? JsonPrimitive)?.doubleOrNull,
messageCount = (obj["message_count"] as? JsonPrimitive)?.intOrNull,
)
}
}
}
/**
* Switch the active model via `config.set {key:"model", value}` — the same
* `_apply_model_switch` path the desktop/CLI `/model` picker uses. [value]
@@ -826,12 +826,24 @@ class ChatViewModel : ViewModel() {
// --- Session management ---
fun refreshSessions() {
val client = apiClient ?: return
val handler = chatHandler ?: return
// On the gateway, list the ACTIVE PROFILE's sessions via `session.list`
// (sessions are profile-bound, so the drawer re-scopes per profile like
// the desktop). The api_server `/api/sessions` reads one shared DB with
// no profile concept, so it's the fallback / SSE path.
val gateway = gatewayClient?.takeIf { streamingEndpoint == "gateway" }
viewModelScope.launch {
client.listSessionsResult().fold(
val result = gateway?.listSessions() ?: apiClient?.listSessionsResult()
result?.fold(
onSuccess = { sessions -> handler.updateSessions(sessions) },
onFailure = { error -> emitError(error, context = "load_sessions") }
onFailure = { error ->
if (gateway != null) {
// Gateway list failed — fall back to the api_server list.
apiClient?.listSessionsResult()?.onSuccess { handler.updateSessions(it) }
} else {
emitError(error, context = "load_sessions")
}
},
)
}
}
@@ -119,6 +119,14 @@ class GatewayClientHarness(
json.parseToJsonElement("""[["/help","Show help"],["/model","Pick model"]]"""),
)
}
"session.list" -> buildJsonObject {
put(
"sessions",
json.parseToJsonElement(
"""[{"id":"s1","title":"Chat 1","message_count":3,"started_at":1234.5,"source":"gateway"}]""",
),
)
}
else -> JsonObject(emptyMap())
}
val reply = if (result != null) {
@@ -559,6 +567,16 @@ class GatewayChatClientTest {
assertEquals("mizu", (create["profile"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `listSessions parses the gateway session list into SessionItems`() {
val sessions = runBlocking { client.listSessions().getOrThrow() }
assertEquals(1, sessions.size)
assertEquals("s1", sessions[0].id)
assertEquals("Chat 1", sessions[0].title)
assertEquals(3, sessions[0].messageCount)
assertEquals("gateway", sessions[0].source)
}
@Test
fun `session create omits profile when none is selected`() {
val r = Recorder()