This commit is contained in:
13 files changed
+1452
-2
No files matched your search
@@ -0,0 +1,52 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* Item returned in the [SubagentListResponse] from `subagent.list` (issue #1089).
|
||||
*/
|
||||
@Serializable
|
||||
data class SubagentListItem(
|
||||
@SerialName("subagent_id") val subagentId: String = "",
|
||||
val goal: String? = null,
|
||||
val status: String? = null,
|
||||
val model: String? = null,
|
||||
@SerialName("elapsed_seconds") val elapsedSeconds: Double? = null,
|
||||
@SerialName("started_at") val startedAt: Double? = null,
|
||||
@SerialName("parent_id") val parentId: String? = null,
|
||||
val depth: Int? = null,
|
||||
@SerialName("delegation_id") val delegationId: String? = null,
|
||||
@SerialName("tool_count") val toolCount: Int? = null,
|
||||
@SerialName("last_tool") val lastTool: String? = null,
|
||||
@SerialName("accepting_steer") val acceptingSteer: Boolean? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response returned from `subagent.list` (issue #1089).
|
||||
*/
|
||||
@Serializable
|
||||
data class SubagentListResponse(
|
||||
val subagents: List<SubagentListItem> = emptyList(),
|
||||
val delegations: List<JsonElement> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Response returned from `subagent.tail` (issue #1089).
|
||||
*
|
||||
* Backend returns either `text` (desktop/TUI gateway) or `tail` (contract).
|
||||
* [content] returns the authoritative transcript text from whichever field is present.
|
||||
*/
|
||||
@Serializable
|
||||
data class SubagentTailResponse(
|
||||
@SerialName("subagent_id") val subagentId: String = "",
|
||||
val tail: String? = null,
|
||||
val text: String? = null,
|
||||
val available: Boolean? = null,
|
||||
val truncated: Boolean? = null,
|
||||
@SerialName("bytes_read") val bytesRead: Long? = null,
|
||||
) {
|
||||
/** Returns authoritative transcript text across backend schema variants. */
|
||||
fun content(): String = text ?: tail ?: ""
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.model.SubagentListResponse
|
||||
import com.m57.hermescontrol.data.model.SubagentTailResponse
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.serializer
|
||||
|
||||
/**
|
||||
* WebSocket RPC repository for subagent operations: `subagent.list` and `subagent.tail` (issue #1089).
|
||||
*/
|
||||
object SubagentRepository {
|
||||
private const val TAG = "SubagentRepository"
|
||||
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the current active subagent roster for a given [sessionId].
|
||||
*
|
||||
* Returns null if RPC fails or is unsupported by server.
|
||||
*/
|
||||
suspend fun listSubagents(sessionId: String): SubagentListResponse? {
|
||||
if (sessionId.isBlank()) return null
|
||||
return try {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.SUBAGENT_LIST,
|
||||
mapOf("session_id" to sessionId),
|
||||
).await()
|
||||
decode<SubagentListResponse>(result)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a rolling live transcript tail for [subagentId].
|
||||
*
|
||||
* Supplies [sessionId] when available so gateway authority checks pass cleanly.
|
||||
* Throws or returns null if unavailable.
|
||||
*/
|
||||
suspend fun tailSubagent(
|
||||
sessionId: String,
|
||||
subagentId: String,
|
||||
maxBytes: Int = 16384,
|
||||
): SubagentTailResponse? {
|
||||
if (subagentId.isBlank()) return null
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"subagent_id" to subagentId,
|
||||
"max_bytes" to maxBytes,
|
||||
)
|
||||
if (sessionId.isNotBlank()) {
|
||||
params["session_id"] = sessionId
|
||||
}
|
||||
val result = HermesWsClient.request(WsMethods.SUBAGENT_TAIL, params).await()
|
||||
return decode<SubagentTailResponse>(result)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal inline fun <reified T> decode(result: Any?): T? {
|
||||
if (result == null) return null
|
||||
val element: JsonElement =
|
||||
when (result) {
|
||||
is JsonElement -> {
|
||||
result
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
anyToJsonElement(result)
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
JsonArray(result.map { anyToJsonElement(it) })
|
||||
}
|
||||
|
||||
else -> {
|
||||
val str = result.toString()
|
||||
try {
|
||||
json.parseToJsonElement(str)
|
||||
} catch (_: Exception) {
|
||||
JsonPrimitive(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
return json.decodeFromJsonElement(serializer<T>(), element)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal fun anyToJsonElement(value: Any?): JsonElement =
|
||||
when (value) {
|
||||
null -> {
|
||||
JsonNull
|
||||
}
|
||||
|
||||
is JsonElement -> {
|
||||
value
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
JsonObject(
|
||||
(value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) },
|
||||
)
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
JsonArray(value.map { anyToJsonElement(it) })
|
||||
}
|
||||
|
||||
is String -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
is Boolean -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
is Number -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
else -> {
|
||||
JsonPrimitive(value.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,14 @@ object WsMethods {
|
||||
/** Kill a single background process (scoped to the active session). */
|
||||
const val PROCESS_KILL = "process.kill"
|
||||
|
||||
// ── Subagents (issue #1089) ───────────────────────────────────────────
|
||||
|
||||
/** List active subagents and delegations for a session. */
|
||||
const val SUBAGENT_LIST = "subagent.list"
|
||||
|
||||
/** Rolling tail snapshot of a subagent's live execution transcript. */
|
||||
const val SUBAGENT_TAIL = "subagent.tail"
|
||||
|
||||
// ── Billing / subscription (issue #628) ─────────────────────────────
|
||||
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
|
||||
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`
|
||||
|
||||
@@ -128,6 +128,7 @@ data class SubagentIndicator(
|
||||
val logs: List<SubagentLogLine> = emptyList(),
|
||||
val durationSeconds: Double? = null,
|
||||
val model: String? = null,
|
||||
val lastEventTimestamp: Long = 0L,
|
||||
) {
|
||||
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
|
||||
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
|
||||
@@ -137,6 +138,20 @@ data class SubagentIndicator(
|
||||
val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued
|
||||
}
|
||||
|
||||
/**
|
||||
* State for inspecting a live subagent's rolling execution transcript (issue #1089).
|
||||
*/
|
||||
data class SubagentTranscriptUiState(
|
||||
val subagentId: String,
|
||||
val text: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val isTruncated: Boolean = false,
|
||||
val bytesRead: Long? = null,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val isEmpty: Boolean get() = text.isEmpty() && !isLoading && error == null
|
||||
}
|
||||
|
||||
enum class MessageRole {
|
||||
USER,
|
||||
ASSISTANT,
|
||||
|
||||
@@ -291,6 +291,11 @@ fun ChatScreen(
|
||||
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(showSubagentInspectionSheet) {
|
||||
if (showSubagentInspectionSheet) {
|
||||
viewModel.hydrateSubagents()
|
||||
}
|
||||
}
|
||||
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val isDark = isSystemInDarkTheme()
|
||||
@@ -732,9 +737,16 @@ fun ChatScreen(
|
||||
SubagentInspectionSheet(
|
||||
indicators = state.subagentIndicators,
|
||||
todos = state.todos,
|
||||
inspectingSubagentId = state.inspectingSubagentId,
|
||||
subagentTranscript = state.subagentTranscript,
|
||||
onToggleTranscript = { subagentId -> viewModel.toggleSubagentTranscript(subagentId) },
|
||||
onRetryTranscript = { viewModel.retrySubagentTranscript() },
|
||||
onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) },
|
||||
onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) },
|
||||
onDismiss = { showSubagentInspectionSheet = false },
|
||||
onDismiss = {
|
||||
showSubagentInspectionSheet = false
|
||||
viewModel.closeSubagentTranscript()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.model.SubagentListItem
|
||||
import com.m57.hermescontrol.data.ws.SubagentRepository
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Delegate managing subagent roster hydration via `subagent.list` and live transcript
|
||||
* tail polling via `subagent.tail` (issue #1089).
|
||||
*/
|
||||
class ChatSubagentsDelegate(
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val runtimeSessionId: () -> String?,
|
||||
private val subagentRepository: SubagentRepository = SubagentRepository,
|
||||
private val pollingIntervalMs: Long = 1500L,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ChatSubagentsDelegate"
|
||||
const val SUBAGENT_TAIL_MAX_BYTES = 16384
|
||||
}
|
||||
|
||||
private var pollingJob: Job? = null
|
||||
private var hydrationJob: Job? = null
|
||||
|
||||
/**
|
||||
* Request the authoritative subagent roster from the gateway and merge it into [ChatUiState].
|
||||
*
|
||||
* A failed request degrades gracefully without clearing existing push-event indicators.
|
||||
*/
|
||||
fun hydrateSubagents(sessionId: String? = runtimeSessionId()) {
|
||||
val targetSession = sessionId?.trim()
|
||||
if (targetSession.isNullOrEmpty()) return
|
||||
|
||||
val requestTime = System.currentTimeMillis()
|
||||
hydrationJob?.cancel()
|
||||
hydrationJob =
|
||||
scope.launch(ioDispatcher) {
|
||||
try {
|
||||
val response = subagentRepository.listSubagents(targetSession) ?: return@launch
|
||||
uiState.update { current ->
|
||||
val merged = mergeSubagentList(current.subagentIndicators, response.subagents, requestTime)
|
||||
current.copy(subagentIndicators = merged)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to hydrate subagents for session $targetSession: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle transcript inspection for [subagentId].
|
||||
*
|
||||
* If already inspecting [subagentId], closes the transcript view; otherwise opens it.
|
||||
*/
|
||||
fun toggleSubagentTranscript(subagentId: String) {
|
||||
if (uiState.value.inspectingSubagentId == subagentId) {
|
||||
closeSubagentTranscript()
|
||||
} else {
|
||||
inspectSubagentTranscript(subagentId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start inspecting a live subagent's execution transcript, initiating bounded polling.
|
||||
*/
|
||||
fun inspectSubagentTranscript(subagentId: String) {
|
||||
if (subagentId.isBlank()) return
|
||||
pollingJob?.cancel()
|
||||
|
||||
uiState.update { current ->
|
||||
current.copy(
|
||||
inspectingSubagentId = subagentId,
|
||||
subagentTranscript =
|
||||
SubagentTranscriptUiState(
|
||||
subagentId = subagentId,
|
||||
isLoading = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pollingJob =
|
||||
scope.launch(ioDispatcher) {
|
||||
val sessionId = runtimeSessionId() ?: return@launch
|
||||
pollTranscriptLoop(sessionId, subagentId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry loading the transcript for the currently inspected subagent.
|
||||
*/
|
||||
fun retryTranscript() {
|
||||
val subagentId = uiState.value.inspectingSubagentId ?: return
|
||||
inspectSubagentTranscript(subagentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop transcript polling and dismiss the transcript disclosure.
|
||||
*/
|
||||
fun closeSubagentTranscript() {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = null
|
||||
uiState.update { current ->
|
||||
current.copy(
|
||||
inspectingSubagentId = null,
|
||||
subagentTranscript = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollTranscriptLoop(
|
||||
sessionId: String,
|
||||
subagentId: String,
|
||||
) {
|
||||
while (scope.isActive) {
|
||||
try {
|
||||
val tailResponse =
|
||||
subagentRepository.tailSubagent(
|
||||
sessionId = sessionId,
|
||||
subagentId = subagentId,
|
||||
maxBytes = SUBAGENT_TAIL_MAX_BYTES,
|
||||
)
|
||||
|
||||
if (tailResponse != null) {
|
||||
val content = tailResponse.content()
|
||||
val truncated = tailResponse.truncated ?: false
|
||||
val bytesRead = tailResponse.bytesRead
|
||||
|
||||
uiState.update { current ->
|
||||
if (current.inspectingSubagentId != subagentId) return@update current
|
||||
val prev = current.subagentTranscript
|
||||
if (prev == null || prev.text != content || prev.isLoading || prev.error != null) {
|
||||
current.copy(
|
||||
subagentTranscript =
|
||||
SubagentTranscriptUiState(
|
||||
subagentId = subagentId,
|
||||
text = content,
|
||||
isLoading = false,
|
||||
isTruncated = truncated,
|
||||
bytesRead = bytesRead,
|
||||
error = null,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uiState.update { current ->
|
||||
if (current.inspectingSubagentId != subagentId) return@update current
|
||||
val prev = current.subagentTranscript
|
||||
if (prev == null || prev.text.isEmpty()) {
|
||||
current.copy(
|
||||
subagentTranscript =
|
||||
SubagentTranscriptUiState(
|
||||
subagentId = subagentId,
|
||||
isLoading = false,
|
||||
error = "Unable to load transcript",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
current.copy(subagentTranscript = prev.copy(isLoading = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error fetching transcript tail for subagent $subagentId: ${e.message}")
|
||||
uiState.update { current ->
|
||||
if (current.inspectingSubagentId != subagentId) return@update current
|
||||
val prev = current.subagentTranscript
|
||||
if (prev == null || prev.text.isEmpty()) {
|
||||
current.copy(
|
||||
subagentTranscript =
|
||||
SubagentTranscriptUiState(
|
||||
subagentId = subagentId,
|
||||
isLoading = false,
|
||||
error = e.message ?: "Unable to load transcript",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
current.copy(subagentTranscript = prev.copy(isLoading = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop polling once the subagent reaches a terminal state (one final refresh is enough)
|
||||
val currentIndicator = uiState.value.subagentIndicators.firstOrNull { it.subagentId == subagentId }
|
||||
if (currentIndicator != null && !currentIndicator.isRunning) {
|
||||
break
|
||||
}
|
||||
|
||||
delay(pollingIntervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges snapshot items from `subagent.list` with current indicators.
|
||||
*
|
||||
* Rules:
|
||||
* - `subagent_id` is the primary identity.
|
||||
* - Protects against races: if an indicator received a push event with timestamp > [requestTime],
|
||||
* its push-event status/type is preserved.
|
||||
* - Preserves local details (logs, taskIndex, taskCount, summary) not provided in the snapshot.
|
||||
* - Does not discard locally known indicators absent from the snapshot.
|
||||
*/
|
||||
internal fun mergeSubagentList(
|
||||
current: List<SubagentIndicator>,
|
||||
listItems: List<SubagentListItem>,
|
||||
requestTime: Long,
|
||||
): List<SubagentIndicator> {
|
||||
val merged = current.toMutableList()
|
||||
|
||||
for (item in listItems) {
|
||||
val subagentId = item.subagentId.trim()
|
||||
if (subagentId.isEmpty() && item.goal.isNullOrBlank()) continue
|
||||
|
||||
val idx =
|
||||
if (subagentId.isNotEmpty()) {
|
||||
merged.indexOfFirst { it.subagentId == subagentId }
|
||||
} else {
|
||||
merged.indexOfFirst { it.subagentId == null && it.goal == item.goal }
|
||||
}
|
||||
|
||||
if (idx >= 0) {
|
||||
val existing = merged[idx]
|
||||
val isPushEventNewer = existing.lastEventTimestamp > requestTime
|
||||
|
||||
val finalStatus =
|
||||
if (isPushEventNewer) {
|
||||
existing.status ?: item.status ?: "running"
|
||||
} else if (existing.isComplete || existing.isFailed || existing.isCancelled) {
|
||||
// Terminal state guard: don't revert to running
|
||||
if (item.status == "completed" || item.status == "failed" ||
|
||||
item.status == "cancelled" || item.status == "stopped"
|
||||
) {
|
||||
item.status
|
||||
} else {
|
||||
existing.status
|
||||
}
|
||||
} else {
|
||||
item.status ?: existing.status ?: "running"
|
||||
}
|
||||
|
||||
val finalType =
|
||||
if (isPushEventNewer) {
|
||||
existing.type
|
||||
} else if (finalStatus == "completed" || finalStatus == "done" || existing.isComplete) {
|
||||
"subagent.complete"
|
||||
} else if (existing.type.isNotBlank()) {
|
||||
existing.type
|
||||
} else {
|
||||
"subagent.progress"
|
||||
}
|
||||
|
||||
merged[idx] =
|
||||
existing.copy(
|
||||
type = finalType,
|
||||
goal = item.goal ?: existing.goal,
|
||||
status = finalStatus,
|
||||
subagentId = if (subagentId.isNotEmpty()) subagentId else existing.subagentId,
|
||||
durationSeconds =
|
||||
if (isPushEventNewer) {
|
||||
existing.durationSeconds ?: item.elapsedSeconds
|
||||
} else {
|
||||
item.elapsedSeconds ?: existing.durationSeconds
|
||||
},
|
||||
model = item.model ?: existing.model,
|
||||
lastEventTimestamp = maxOf(existing.lastEventTimestamp, requestTime),
|
||||
)
|
||||
} else {
|
||||
val status = item.status ?: "running"
|
||||
val isComp = status == "completed" || status == "done"
|
||||
val type = if (isComp) "subagent.complete" else "subagent.progress"
|
||||
|
||||
merged.add(
|
||||
SubagentIndicator(
|
||||
type = type,
|
||||
goal = item.goal,
|
||||
status = status,
|
||||
subagentId = subagentId.takeIf { it.isNotEmpty() },
|
||||
durationSeconds = item.elapsedSeconds,
|
||||
model = item.model,
|
||||
lastEventTimestamp = requestTime,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,10 @@ data class ChatUiState(
|
||||
val btwState: BtwUiState? = null,
|
||||
/** Subagent delegation indicators (issue #538) — transient UI state. */
|
||||
val subagentIndicators: List<SubagentIndicator> = emptyList(),
|
||||
/** Currently inspected subagent ID for live transcript tail (issue #1089). */
|
||||
val inspectingSubagentId: String? = null,
|
||||
/** Transient live transcript tail state for the inspected subagent (issue #1089). */
|
||||
val subagentTranscript: SubagentTranscriptUiState? = null,
|
||||
/** Agent todo / plan items (issue #736). */
|
||||
val todos: List<TodoItem> = emptyList(),
|
||||
// Session resume recovery (desktop parity: bounded auto-retry + error UI)
|
||||
@@ -473,6 +477,14 @@ class ChatViewModel(
|
||||
trackRequest = { id, method -> trackRequest(id, method) },
|
||||
)
|
||||
|
||||
private val subagentsDelegate =
|
||||
ChatSubagentsDelegate(
|
||||
uiState = _uiState,
|
||||
scope = viewModelScope,
|
||||
ioDispatcher = ioDispatcher,
|
||||
runtimeSessionId = { runtimeSessionId ?: _uiState.value.currentSessionId },
|
||||
)
|
||||
|
||||
private val streamingController =
|
||||
ChatStreamingController(
|
||||
scope = viewModelScope,
|
||||
@@ -534,6 +546,7 @@ class ChatViewModel(
|
||||
status == ConnectionStatus.AUTH_EXPIRED
|
||||
) {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
subagentsDelegate.closeSubagentTranscript()
|
||||
// The runtime session id is only valid while the socket
|
||||
// owns it — a dropped connection may mean the gateway
|
||||
// closed/pruned the session (or restarted, wiping the
|
||||
@@ -1139,6 +1152,7 @@ class ChatViewModel(
|
||||
val generation = request?.generation ?: sessionGeneration
|
||||
resumedGeneration = generation
|
||||
finishResumeWhenHydrated(generation)
|
||||
subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId)
|
||||
// Reconnect replay: resume payload can carry `pending_approval`
|
||||
// (server `_session_info_payload`); surface it, then ask for
|
||||
// the full queue in case more are parked.
|
||||
@@ -2182,6 +2196,22 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun hydrateSubagents(sessionId: String? = null) {
|
||||
subagentsDelegate.hydrateSubagents(sessionId ?: runtimeSessionId ?: _uiState.value.currentSessionId)
|
||||
}
|
||||
|
||||
fun toggleSubagentTranscript(subagentId: String) {
|
||||
subagentsDelegate.toggleSubagentTranscript(subagentId)
|
||||
}
|
||||
|
||||
fun retrySubagentTranscript() {
|
||||
subagentsDelegate.retryTranscript()
|
||||
}
|
||||
|
||||
fun closeSubagentTranscript() {
|
||||
subagentsDelegate.closeSubagentTranscript()
|
||||
}
|
||||
|
||||
fun createNewSession(setLoading: Boolean = true) {
|
||||
// A fresh create has no persisted row until the first prompt.
|
||||
sessionHasServerPresence = false
|
||||
@@ -3451,6 +3481,7 @@ class ChatViewModel(
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
subagentsDelegate.closeSubagentTranscript()
|
||||
// PERF-16: Don't disconnect the global HermesWsClient singleton when
|
||||
// leaving the Chat screen — it's used by background notification reply.
|
||||
}
|
||||
|
||||
@@ -946,6 +946,7 @@ object ChatWsEventReducer {
|
||||
logs = trimmedLogs,
|
||||
durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null),
|
||||
model = model ?: (if (idx >= 0) indicators[idx].model else null),
|
||||
lastEventTimestamp = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
if (idx >= 0) {
|
||||
|
||||
+235
-1
@@ -4,20 +4,25 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.AltRoute
|
||||
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||
@@ -32,11 +37,14 @@ import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.HourglassEmpty
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -45,6 +53,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -63,6 +72,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||
import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState
|
||||
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||
|
||||
/**
|
||||
@@ -73,6 +83,10 @@ import com.m57.hermescontrol.ui.chat.TodoItem
|
||||
fun SubagentInspectionSheet(
|
||||
indicators: List<SubagentIndicator> = emptyList(),
|
||||
todos: List<TodoItem> = emptyList(),
|
||||
inspectingSubagentId: String? = null,
|
||||
subagentTranscript: SubagentTranscriptUiState? = null,
|
||||
onToggleTranscript: ((String) -> Unit)? = null,
|
||||
onRetryTranscript: (() -> Unit)? = null,
|
||||
onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null,
|
||||
onStopSubagent: ((SubagentIndicator) -> Unit)? = null,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -197,7 +211,7 @@ fun SubagentInspectionSheet(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "SUBAGENTS",
|
||||
text = stringResource(R.string.subagent_subagents_header).uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
@@ -211,8 +225,14 @@ fun SubagentInspectionSheet(
|
||||
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
|
||||
},
|
||||
) { _, indicator ->
|
||||
val isInspectingThis =
|
||||
indicator.subagentId != null && indicator.subagentId == inspectingSubagentId
|
||||
InspectionItemCard(
|
||||
indicator = indicator,
|
||||
isInspectingTranscript = isInspectingThis,
|
||||
transcriptState = if (isInspectingThis) subagentTranscript else null,
|
||||
onToggleTranscript = onToggleTranscript,
|
||||
onRetryTranscript = onRetryTranscript,
|
||||
onSteer =
|
||||
if (onSteerSubagent != null) {
|
||||
{ message -> onSteerSubagent(indicator, message) }
|
||||
@@ -448,6 +468,10 @@ private fun TodoInspectionCard(
|
||||
@Composable
|
||||
private fun InspectionItemCard(
|
||||
indicator: SubagentIndicator,
|
||||
isInspectingTranscript: Boolean = false,
|
||||
transcriptState: SubagentTranscriptUiState? = null,
|
||||
onToggleTranscript: ((String) -> Unit)? = null,
|
||||
onRetryTranscript: (() -> Unit)? = null,
|
||||
onSteer: ((String) -> Unit)? = null,
|
||||
onStop: (() -> Unit)? = null,
|
||||
) {
|
||||
@@ -619,6 +643,44 @@ private fun InspectionItemCard(
|
||||
}
|
||||
}
|
||||
|
||||
// Full Rolling Transcript Disclosure (issue #1089)
|
||||
if (!indicator.subagentId.isNullOrBlank() && onToggleTranscript != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { onToggleTranscript(indicator.subagentId) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("subagent_transcript_toggle_${indicator.subagentId}"),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isInspectingTranscript) Icons.Filled.ExpandLess else Icons.Filled.Terminal,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text =
|
||||
if (isInspectingTranscript) {
|
||||
stringResource(R.string.subagent_hide_transcript)
|
||||
} else {
|
||||
stringResource(R.string.subagent_inspect_transcript)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = isInspectingTranscript) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
SubagentTranscriptView(
|
||||
state = transcriptState,
|
||||
onRetry = onRetryTranscript,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive Controls for active subagent (issue #1030)
|
||||
if (indicator.isRunning || indicator.isSteered) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
@@ -721,3 +783,175 @@ private fun InspectionItemCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubagentTranscriptView(
|
||||
state: SubagentTranscriptUiState?,
|
||||
onRetry: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth().testTag("subagent_transcript_container"),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
when {
|
||||
state == null || (state.isLoading && state.text.isEmpty()) -> {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.subagent_transcript_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.error != null && state.text.isEmpty() -> {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = state.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
if (onRetry != null) {
|
||||
OutlinedButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.testTag("subagent_transcript_retry"),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.subagent_transcript_retry),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.isEmpty -> {
|
||||
Text(
|
||||
text = stringResource(R.string.subagent_transcript_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.testTag("subagent_transcript_empty"),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Header badges (truncation note & background loading indicator)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.subagent_live_transcript),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontSize = 9.sp,
|
||||
)
|
||||
|
||||
if (state.isTruncated) {
|
||||
Text(
|
||||
text = stringResource(R.string.subagent_transcript_truncated),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontSize = 8.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
LinearProgressIndicator(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(state.text) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 60.dp, max = 240.dp)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Text(
|
||||
text = state.text,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.testTag("subagent_transcript_text"),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.error != null && onRetry != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = state.error,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Autorenew,
|
||||
contentDescription = stringResource(R.string.subagent_transcript_retry),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user