feat(chat): support multi-select, batch multi-question clarify prompts, and expiry (fixes #1045) (#1055)

This commit is contained in:
M57 authored and GitHub committed 2026-09-03 21:38:57 +03:00
1 parent 50773945f9
commit 6d2362ba8a
10 files changed
+649 -109

No files matched your search

@@ -136,31 +136,88 @@ object EventParser {
// older client or test that still uses the legacy field names. (Issue #206)
// Batch clarify (issue #18450): gateway sends "questions" array of {qid, question, choices, multi_select}.
val rawQuestions = payload?.get("questions") as? List<*>
val firstQuestion = rawQuestions?.firstOrNull() as? Map<*, *>
val text =
payload?.get("question") as? String
?: payload?.get("text") as? String
?: if (rawQuestions != null && rawQuestions.size > 1) {
rawQuestions
.mapIndexedNotNull { index, item ->
val q = (item as? Map<*, *>)?.get("question") as? String
q?.let { "${index + 1}. $it" }
}.joinToString("\n\n")
} else {
firstQuestion?.get("question") as? String
}
val rawOptions =
payload?.get("choices")
?: payload?.get("options")
?: firstQuestion?.get("choices")
val clarifyId = payload?.get("clarify_id") as? String ?: payload?.get("request_id") as? String
val questionId = firstQuestion?.get("qid") as? String
val parsedQuestions =
if (rawQuestions != null && rawQuestions.isNotEmpty()) {
rawQuestions.mapIndexedNotNull { index, item ->
val map = item as? Map<*, *> ?: return@mapIndexedNotNull null
val qText = map["question"] as? String ?: return@mapIndexedNotNull null
val qid = map["qid"] as? String ?: "q$index"
@Suppress("UNCHECKED_CAST")
val qChoices = (map["choices"] as? List<*>)?.filterIsInstance<String>() ?: emptyList()
val qMulti = map["multi_select"] as? Boolean ?: false
WsEvent.ClarifyQuestion(
qid = qid,
question = qText,
choices = qChoices,
multiSelect = qMulti,
)
}
} else {
val text =
payload?.get("question") as? String
?: payload?.get("text") as? String
val rawOptions = payload?.get("choices") ?: payload?.get("options")
@Suppress("UNCHECKED_CAST")
val options = (rawOptions as? List<*>)?.filterIsInstance<String>() ?: emptyList()
val qid = payload?.get("qid") as? String ?: payload?.get("question_id") as? String
val multi = payload?.get("multi_select") as? Boolean ?: false
if ((!text.isNullOrBlank() || options.isNotEmpty()) && qid != null) {
listOf(
WsEvent.ClarifyQuestion(
qid = qid,
question = text.orEmpty(),
choices = options,
multiSelect = multi,
),
)
} else {
emptyList()
}
}
val primary = parsedQuestions.firstOrNull()
val legacyText =
if (parsedQuestions.size > 1) {
parsedQuestions.mapIndexed { index, q -> "${index + 1}. ${q.question}" }.joinToString("\n\n")
} else if (primary != null) {
primary.question
} else {
payload?.get("question") as? String
?: payload?.get("text") as? String
}
@Suppress("UNCHECKED_CAST")
val options = (rawOptions as? List<*>)?.filterIsInstance<String>()
WsEvent.ClarifyRequest(text, options, clarifyId, sessionId, questionId)
val legacyOptions =
primary?.choices
?: (payload?.get("choices") ?: payload?.get("options"))
.let { (it as? List<*>)?.filterIsInstance<String>() }
val legacyQid =
primary?.qid
?: payload?.get("qid") as? String
?: payload?.get("question_id") as? String
val legacyMulti =
primary?.multiSelect
?: payload?.get("multi_select") as? Boolean
?: false
WsEvent.ClarifyRequest(
text = legacyText,
options = legacyOptions,
clarifyId = clarifyId,
sessionId = sessionId,
questionId = legacyQid,
multiSelect = legacyMulti,
questions = parsedQuestions,
)
}
"clarify.expire" -> {
val clarifyId = payload?.get("request_id") as? String ?: payload?.get("clarify_id") as? String
WsEvent.ClarifyExpire(clarifyId, sessionId)
}
"status.update" -> {
@@ -135,12 +135,26 @@ sealed class WsEvent {
// ── Interactive ──────────────────────────────────────────────────────
data class ClarifyQuestion(
val qid: String = "q0",
val question: String = "",
val choices: List<String> = emptyList(),
val multiSelect: Boolean = false,
)
data class ClarifyRequest(
val text: String?,
val options: List<String>?,
val clarifyId: String? = null,
val sessionId: String? = null,
val questionId: String? = null,
val multiSelect: Boolean = false,
val questions: List<ClarifyQuestion> = emptyList(),
) : WsEvent()
data class ClarifyExpire(
val clarifyId: String? = null,
val sessionId: String? = null,
) : WsEvent()
/**
@@ -720,6 +720,7 @@ fun ChatScreen(
viewModel = viewModel,
clarifyRequest = state.clarifyRequest,
onRespondClarify = viewModel::respondToClarify,
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
onDismissClarify = viewModel::dismissClarify,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
@@ -377,12 +377,40 @@ data class SessionUi(
val depth: Int = 0,
)
data class ClarifyQuestionUi(
val qid: String = "q0",
val question: String = "",
val choices: List<String> = emptyList(),
val multiSelect: Boolean = false,
)
data class ClarifyUi(
val text: String,
val options: List<String>,
val options: List<String> = emptyList(),
val clarifyId: String? = null,
val questionId: String? = null,
)
val multiSelect: Boolean = false,
val questions: List<ClarifyQuestionUi> = emptyList(),
) {
/**
* Normalized list of questions to display. Guarantees at least one question
* entry even for legacy single-question clarify events.
*/
val resolvedQuestions: List<ClarifyQuestionUi>
get() =
if (questions.isNotEmpty()) {
questions
} else {
listOf(
ClarifyQuestionUi(
qid = questionId ?: "q0",
question = text,
choices = options,
multiSelect = multiSelect,
),
)
}
}
/**
* State for the context-aware side-question bottom sheet (issue #1015, `/btw`).
@@ -3377,44 +3405,107 @@ class ChatViewModel(
*/
fun dismissClarify() {
val sessionId = _uiState.value.currentSessionId ?: return
val clarifyId = _uiState.value.clarifyRequest?.clarifyId
val questionId = _uiState.value.clarifyRequest?.questionId
val clarify = _uiState.value.clarifyRequest
val clarifyId = clarify?.clarifyId
// Raw batch questions (non-empty only for true batch payloads).
// Legacy singles keep questions empty and rely on questionId (nullable).
val isBatch = !clarify?.questions.isNullOrEmpty()
val displayQuestions = clarify?.resolvedQuestions.orEmpty()
_uiState.update { it.copy(clarifyRequest = null) }
addSystemMessage("Clarify dismissed — no answer sent", persist = true)
viewModelScope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"response" to CLARIFY_DISMISS_RESPONSE,
"answer" to CLARIFY_DISMISS_RESPONSE,
if (isBatch) {
// Send dismissal for every question in the batch
for (q in displayQuestions) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"response" to CLARIFY_DISMISS_RESPONSE,
"answer" to CLARIFY_DISMISS_RESPONSE,
"question_id" to q.qid,
)
if (clarifyId != null) {
params["clarify_id"] = clarifyId
params["request_id"] = clarifyId
}
wsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
)
}
} else {
// Legacy: only send question_id when the original payload had one.
val questionId = clarify?.questionId
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"response" to CLARIFY_DISMISS_RESPONSE,
"answer" to CLARIFY_DISMISS_RESPONSE,
)
if (clarifyId != null) {
params["clarify_id"] = clarifyId
params["request_id"] = clarifyId
}
if (questionId != null) {
params["question_id"] = questionId
}
wsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
)
if (clarifyId != null) {
params["clarify_id"] = clarifyId
params["request_id"] = clarifyId
}
if (questionId != null) {
params["question_id"] = questionId
}
wsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
)
}
}
fun respondToClarify(option: String) {
val clarify = _uiState.value.clarifyRequest
// Only use synthesized qid when this is a true batch or legacy with explicit qid.
val qid =
if (clarify != null &&
(clarify.questionId != null || clarify.questions.isNotEmpty())
) {
clarify.resolvedQuestions.firstOrNull()?.qid ?: clarify.questionId
} else {
null
}
if (qid != null && clarify?.resolvedQuestions?.size == 1) {
respondToClarifyBatch(mapOf(qid to option))
} else {
respondToClarifyBatch(emptyMap(), singleFallbackAnswer = option)
}
}
fun respondToClarifyBatch(
answers: Map<String, String>,
singleFallbackAnswer: String? = null,
) {
val sessionId = _uiState.value.currentSessionId ?: return
val clarifyId = _uiState.value.clarifyRequest?.clarifyId
val questionId = _uiState.value.clarifyRequest?.questionId
val clarify = _uiState.value.clarifyRequest
val clarifyId = clarify?.clarifyId
val isBatch = !clarify?.questions.isNullOrEmpty()
val questions = clarify?.resolvedQuestions.orEmpty()
_uiState.update { it.copy(clarifyRequest = null) }
val displayContent =
if (questions.size > 1) {
questions
.mapIndexed { index, q ->
val ans = answers[q.qid]?.trim().orEmpty()
"${index + 1}. ${ans.ifEmpty { "(Skipped)" }}"
}.joinToString("\n")
} else {
val loneAns = answers.values.firstOrNull()?.trim() ?: singleFallbackAnswer?.trim().orEmpty()
loneAns
}
val userMessage =
ChatMessage(
role = MessageRole.USER,
content = option,
content = displayContent,
)
_uiState.update { state ->
@@ -3429,24 +3520,49 @@ class ChatViewModel(
}
viewModelScope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"response" to option,
"answer" to option,
if (isBatch) {
for (q in questions) {
val ans = answers[q.qid]?.trim().orEmpty()
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"response" to ans,
"answer" to ans,
"question_id" to q.qid,
)
if (clarifyId != null) {
params["clarify_id"] = clarifyId
params["request_id"] = clarifyId
}
wsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
)
}
} else {
// Legacy single: only include question_id when explicitly present.
val qid = clarify?.questionId
val ans = answers.values.firstOrNull() ?: singleFallbackAnswer.orEmpty()
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"response" to ans,
"answer" to ans,
)
if (clarifyId != null) {
params["clarify_id"] = clarifyId
params["request_id"] = clarifyId
}
if (qid != null) {
params["question_id"] = qid
}
wsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
)
if (clarifyId != null) {
params["clarify_id"] = clarifyId
params["request_id"] = clarifyId
}
if (questionId != null) {
params["question_id"] = questionId
}
wsClient.send(
method = WsMethods.CLARIFY_RESPOND,
params = params,
onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) },
)
}
}
@@ -67,6 +67,7 @@ object ChatWsEventReducer {
is WsEvent.ToolComplete -> event.sessionId
is WsEvent.ToolOutputRisk -> event.sessionId
is WsEvent.ClarifyRequest -> event.sessionId
is WsEvent.ClarifyExpire -> event.sessionId
is WsEvent.ToolProgress -> event.sessionId
is WsEvent.ToolGenerating -> event.sessionId
is WsEvent.SubagentEvent -> event.sessionId
@@ -128,6 +129,8 @@ object ChatWsEventReducer {
is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event)
is WsEvent.ClarifyExpire -> onClarifyExpire(state, streamingState, event)
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
is WsEvent.BtwComplete -> onBtwComplete(state, streamingState, event)
@@ -636,8 +639,25 @@ object ChatWsEventReducer {
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ClarifyRequest,
): ReducerResult =
ReducerResult(
): ReducerResult {
val questions =
if (event.questions.isNotEmpty()) {
event.questions.map {
ClarifyQuestionUi(
qid = it.qid,
question = it.question,
choices = it.choices,
multiSelect = it.multiSelect,
)
}
} else {
// Legacy single-question payload: keep questions empty so the wire
// layer can tell legacy apart from a true batch. UI synthesizes
// via ClarifyUi.resolvedQuestions; wire uses text/options/questionId.
emptyList()
}
return ReducerResult(
state =
state.copy(
clarifyRequest =
@@ -646,10 +666,27 @@ object ChatWsEventReducer {
options = event.options.orEmpty(),
clarifyId = event.clarifyId,
questionId = event.questionId,
multiSelect = event.multiSelect,
questions = questions,
),
isAgentTyping = false,
),
)
}
private fun onClarifyExpire(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ClarifyExpire,
): ReducerResult =
if (event.clarifyId == null || state.clarifyRequest?.clarifyId == event.clarifyId) {
ReducerResult(
state = state.copy(clarifyRequest = null),
streamingState = streamingState,
)
} else {
ReducerResult(state = state, streamingState = streamingState)
}
// ── ReviewSummary (Self-improvement background review) ──────────────
@@ -42,6 +42,8 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -85,6 +87,7 @@ import com.m57.hermescontrol.theme.CodeTerminalBg
import com.m57.hermescontrol.theme.CodeTerminalBorder
import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import kotlinx.coroutines.delay
@@ -429,19 +432,27 @@ private fun copyToClipboard(
// ── ClarifyBubble ─────────────────────────────────────────────────────────
/**
* Centered dashed-border bubble showing a clarify question with
* selectable option chips and a dismiss button.
* Centered dashed-border bubble showing a clarify question (or batch of questions) with
* selectable option chips, multi-select toggles, open-ended input, and a dismiss button.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ClarifyBubble(
text: String,
options: List<String>,
onOptionSelected: (String) -> Unit,
clarifyRequest: ClarifyUi,
onRespondSingle: (String) -> Unit,
onRespondBatch: (Map<String, String>) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var typedText by remember { mutableStateOf("") }
val questions = clarifyRequest.resolvedQuestions
val isBatch = questions.size > 1
var selectedChoicesByQid by remember(clarifyRequest) {
mutableStateOf<Map<String, Set<String>>>(emptyMap())
}
var customTextByQid by remember(clarifyRequest) {
mutableStateOf<Map<String, String>>(emptyMap())
}
Surface(
modifier =
@@ -458,57 +469,170 @@ fun ClarifyBubble(
),
) {
Column(
modifier = Modifier.padding(12.dp),
modifier = Modifier.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (options.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
options.forEach { option ->
SuggestionChip(
onClick = { onOptionSelected(option) },
label = { Text(option) },
colors =
SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
labelColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
)
questions.forEachIndexed { index, q ->
if (index > 0) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 12.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
if (isBatch) {
Text(
text = "${index + 1}. ${q.question}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
} else {
Text(
text = q.question,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
}
if (q.multiSelect) {
Spacer(Modifier.height(2.dp))
Text(
text = "Select all that apply",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
)
}
if (q.choices.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
q.choices.forEach { choice ->
val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet()
val isSelected = selectedSet.contains(choice)
FilterChip(
selected = isSelected,
onClick = {
if (q.multiSelect) {
val updated = if (isSelected) selectedSet - choice else selectedSet + choice
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
} else {
if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) {
// Fast 1-tap respond for lone single-select question when no custom text is entered
onRespondSingle(choice)
} else {
val updated = if (isSelected) emptySet() else setOf(choice)
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
}
}
},
label = { Text(choice) },
leadingIcon =
if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
}
} else {
null
},
)
}
}
}
Spacer(Modifier.height(8.dp))
val typed = customTextByQid[q.qid].orEmpty()
OutlinedTextField(
value = typed,
onValueChange = { newText ->
customTextByQid = customTextByQid + (q.qid to newText)
},
label = {
Text(
if (q.choices.isEmpty()) {
stringResource(R.string.message_your_response)
} else {
"Other (optional)"
},
)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = typedText,
onValueChange = { typedText = it },
label = { Text(stringResource(R.string.message_your_response)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
val hasAnyInput =
questions.any { q ->
(selectedChoicesByQid[q.qid]?.isNotEmpty() == true) ||
(!customTextByQid[q.qid].isNullOrBlank())
}
FilledTonalButton(
onClick = {
if (typedText.isNotBlank()) {
onOptionSelected(typedText)
typedText = ""
if (isBatch) {
val answers =
questions.associate { q ->
val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty()
val custom = customTextByQid[q.qid]?.trim().orEmpty()
val finalAns =
when {
selectedList.isNotEmpty() && custom.isNotEmpty() -> {
(selectedList + custom).joinToString(", ")
}
selectedList.isNotEmpty() -> {
selectedList.joinToString(", ")
}
else -> {
custom
}
}
q.qid to finalAns
}
onRespondBatch(answers)
} else {
val q = questions.first()
val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty()
val custom = customTextByQid[q.qid]?.trim().orEmpty()
val finalAns =
when {
selectedList.isNotEmpty() && custom.isNotEmpty() -> {
(selectedList + custom).joinToString(", ")
}
selectedList.isNotEmpty() -> {
selectedList.joinToString(", ")
}
else -> {
custom
}
}
if (finalAns.isNotBlank()) {
onRespondSingle(finalAns)
}
}
},
enabled = typedText.isNotBlank(),
enabled = hasAnyInput,
) {
Text("Send")
Text(if (isBatch) "Submit All" else "Send")
}
TextButton(onClick = onDismiss) {
Text("Dismiss")
@@ -518,6 +642,28 @@ fun ClarifyBubble(
}
}
/**
* Backward-compatible overload for legacy callers and unit tests.
*/
@Composable
fun ClarifyBubble(
text: String,
options: List<String>,
onOptionSelected: (String) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
ClarifyBubble(
clarifyRequest = ClarifyUi(text = text, options = options),
onRespondSingle = onOptionSelected,
onRespondBatch = { answers ->
answers.values.firstOrNull()?.let(onOptionSelected)
},
onDismiss = onDismiss,
modifier = modifier,
)
}
// ── SubagentCard ──────────────────────────────────────────────────────────
/**
@@ -67,6 +67,7 @@ fun FullBleedChatList(
viewModel: ChatViewModel,
clarifyRequest: ClarifyUi? = null,
onRespondClarify: ((String) -> Unit)? = null,
onRespondClarifyBatch: ((Map<String, String>) -> Unit)? = null,
onDismissClarify: (() -> Unit)? = null,
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
savingAttachmentPath: String? = null,
@@ -299,9 +300,9 @@ fun FullBleedChatList(
if (clarifyRequest != null) {
item(key = "clarify_bubble") {
ClarifyBubble(
text = clarifyRequest.text,
options = clarifyRequest.options,
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
clarifyRequest = clarifyRequest,
onRespondSingle = { option -> onRespondClarify?.invoke(option) },
onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) },
onDismiss = { onDismissClarify?.invoke() },
)
}