mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(android): hide thinking control and gate sends for models without reasoning support (#101002)
This commit is contained in:
parent
f309c6054b
commit
b2cecd8ece
10 changed files with 283 additions and 85 deletions
|
|
@ -5787,7 +5787,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 218,
|
||||
"line": 223,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt",
|
||||
"source": "Type a message…",
|
||||
"surface": "android",
|
||||
|
|
@ -5795,7 +5795,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 231,
|
||||
"line": 236,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt",
|
||||
"source": "Gateway is offline. Text messages are queued and sent after reconnecting.",
|
||||
"surface": "android",
|
||||
|
|
@ -5803,7 +5803,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 260,
|
||||
"line": 266,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt",
|
||||
"source": "Select thinking level",
|
||||
"surface": "android",
|
||||
|
|
@ -5811,7 +5811,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 284,
|
||||
"line": 291,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt",
|
||||
"source": "Attach",
|
||||
"surface": "android",
|
||||
|
|
@ -5819,7 +5819,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 299,
|
||||
"line": 306,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt",
|
||||
"source": "Refresh",
|
||||
"surface": "android",
|
||||
|
|
@ -5827,7 +5827,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 307,
|
||||
"line": 314,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt",
|
||||
"source": "Abort",
|
||||
"surface": "android",
|
||||
|
|
@ -5835,7 +5835,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 356,
|
||||
"line": 363,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt",
|
||||
"source": "Send",
|
||||
"surface": "android",
|
||||
|
|
@ -5843,7 +5843,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 382,
|
||||
"line": 389,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt",
|
||||
"source": "No commands found",
|
||||
"surface": "android",
|
||||
|
|
@ -6131,7 +6131,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 290,
|
||||
"line": 291,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Chat needs attention",
|
||||
"surface": "android",
|
||||
|
|
@ -6139,7 +6139,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 448,
|
||||
"line": 450,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "All",
|
||||
"surface": "android",
|
||||
|
|
@ -6147,7 +6147,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 506,
|
||||
"line": 508,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "OpenClaw",
|
||||
"surface": "android",
|
||||
|
|
@ -6155,7 +6155,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 527,
|
||||
"line": 529,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "New chat",
|
||||
"surface": "android",
|
||||
|
|
@ -6163,7 +6163,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 532,
|
||||
"line": 534,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "More new chat options",
|
||||
"surface": "android",
|
||||
|
|
@ -6171,7 +6171,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 547,
|
||||
"line": 549,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Refresh chat",
|
||||
"surface": "android",
|
||||
|
|
@ -6179,7 +6179,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 550,
|
||||
"line": 552,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Chat",
|
||||
"surface": "android",
|
||||
|
|
@ -6187,7 +6187,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 698,
|
||||
"line": 700,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Loading session",
|
||||
"surface": "android",
|
||||
|
|
@ -6195,7 +6195,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 725,
|
||||
"line": 727,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Jump to latest",
|
||||
"surface": "android",
|
||||
|
|
@ -6203,7 +6203,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 751,
|
||||
"line": 753,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Ready when you are",
|
||||
"surface": "android",
|
||||
|
|
@ -6211,7 +6211,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 782,
|
||||
"line": 784,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Fix connection",
|
||||
"surface": "android",
|
||||
|
|
@ -6219,7 +6219,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 783,
|
||||
"line": 785,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Copy diagnostics",
|
||||
"surface": "android",
|
||||
|
|
@ -6227,7 +6227,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 967,
|
||||
"line": 969,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Preparing audio…",
|
||||
"surface": "android",
|
||||
|
|
@ -6235,7 +6235,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 967,
|
||||
"line": 969,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Speaking…",
|
||||
"surface": "android",
|
||||
|
|
@ -6243,7 +6243,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 988,
|
||||
"line": 990,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Tools running",
|
||||
"surface": "android",
|
||||
|
|
@ -6251,7 +6251,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 990,
|
||||
"line": 992,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "OpenClaw is working",
|
||||
"surface": "android",
|
||||
|
|
@ -6259,7 +6259,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 993,
|
||||
"line": 995,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "+${toolCalls.size - 4} more",
|
||||
"surface": "android",
|
||||
|
|
@ -6267,7 +6267,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1003,
|
||||
"line": 1005,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Thinking",
|
||||
"surface": "android",
|
||||
|
|
@ -6275,7 +6275,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1004,
|
||||
"line": 1006,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "OpenClaw is preparing a response.",
|
||||
"surface": "android",
|
||||
|
|
@ -6283,7 +6283,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1161,
|
||||
"line": 1165,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Stop",
|
||||
"surface": "android",
|
||||
|
|
@ -6291,7 +6291,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1227,
|
||||
"line": 1231,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Default",
|
||||
"surface": "android",
|
||||
|
|
@ -6299,7 +6299,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1293,
|
||||
"line": 1297,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Pin model",
|
||||
"surface": "android",
|
||||
|
|
@ -6307,7 +6307,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1293,
|
||||
"line": 1297,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Unpin model",
|
||||
"surface": "android",
|
||||
|
|
@ -6315,7 +6315,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1310,
|
||||
"line": 1314,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "No commands found",
|
||||
"surface": "android",
|
||||
|
|
@ -6323,7 +6323,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1372,
|
||||
"line": 1376,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Gateway offline",
|
||||
"surface": "android",
|
||||
|
|
@ -6331,7 +6331,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1464,
|
||||
"line": 1472,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Attach image",
|
||||
"surface": "android",
|
||||
|
|
@ -6339,7 +6339,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1483,
|
||||
"line": 1491,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Message OpenClaw",
|
||||
"surface": "android",
|
||||
|
|
@ -6347,7 +6347,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1498,
|
||||
"line": 1506,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Open voice",
|
||||
"surface": "android",
|
||||
|
|
@ -6355,7 +6355,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1547,
|
||||
"line": 1555,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Remove attachment",
|
||||
"surface": "android",
|
||||
|
|
@ -6363,7 +6363,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1568,
|
||||
"line": 1576,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Main",
|
||||
"surface": "android",
|
||||
|
|
@ -6371,7 +6371,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 1624,
|
||||
"line": 1632,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "Send",
|
||||
"surface": "android",
|
||||
|
|
@ -6379,7 +6379,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1663,
|
||||
"line": 1672,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
||||
"source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}",
|
||||
"surface": "android",
|
||||
|
|
@ -6387,7 +6387,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 291,
|
||||
"line": 297,
|
||||
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt",
|
||||
"source": "CHAT ERROR",
|
||||
"surface": "android",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import ai.openclaw.app.gateway.GatewaySession
|
|||
import ai.openclaw.app.gateway.parseChatSendAck
|
||||
import ai.openclaw.app.parseGatewayModels
|
||||
import ai.openclaw.app.resolveAgentIdFromMainSessionKey
|
||||
import ai.openclaw.app.ui.chat.thinkingSupportedForSelection
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -763,6 +764,15 @@ class ChatController internal constructor(
|
|||
val pendingSelection = pendingModelSelections[sessionKey]
|
||||
if (pendingSelection != null && !pendingSelection.await()) return false
|
||||
if (_sessionKey.value != sessionKey) return false
|
||||
// agent-command.ts throws for explicit unsupported levels, so hidden controls must send off.
|
||||
// Applied at enqueue time too so durable rows never persist a level the selected model
|
||||
// rejects; reconnect flushes with a cleared catalog fail open, matching pre-gating behavior.
|
||||
val thinking =
|
||||
if (thinkingSupportedForSelection(_selectedModelRef.value, _modelCatalog.value)) {
|
||||
normalizeThinking(thinkingLevel)
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
if (!_healthOk.value) {
|
||||
// Offline capture: text-only commands become durable outbox rows and flush on reconnect.
|
||||
// Attachments stay blocked (text-only v1) so large payloads never sit in the database.
|
||||
|
|
@ -770,12 +780,11 @@ class ChatController internal constructor(
|
|||
updateErrorText("Gateway health not OK; cannot send")
|
||||
return false
|
||||
}
|
||||
return enqueueOfflineCommand(text = trimmed, thinkingLevel = normalizeThinking(thinkingLevel))
|
||||
return enqueueOfflineCommand(text = trimmed, thinkingLevel = thinking)
|
||||
}
|
||||
|
||||
val runId = UUID.randomUUID().toString()
|
||||
val text = if (trimmed.isEmpty() && attachments.isNotEmpty()) "See attached." else trimmed
|
||||
val thinking = normalizeThinking(thinkingLevel)
|
||||
|
||||
// Optimistic user message keeps the composer responsive while chat.send and history refresh complete.
|
||||
val userContent =
|
||||
|
|
@ -1585,14 +1594,25 @@ class ChatController internal constructor(
|
|||
gatewayId: String,
|
||||
): OutboxSendResult =
|
||||
try {
|
||||
val queuedSessionKey = normalizeRequestedSessionKey(item.sessionKey)
|
||||
// Android only knows the active session's selected model. Unknown queued sessions fail
|
||||
// open, preserving the thinking level captured when they were enqueued.
|
||||
val thinking =
|
||||
if (
|
||||
queuedSessionKey == _sessionKey.value &&
|
||||
!thinkingSupportedForSelection(_selectedModelRef.value, _modelCatalog.value)
|
||||
) {
|
||||
"off"
|
||||
} else {
|
||||
item.thinkingLevel
|
||||
}
|
||||
val params =
|
||||
buildJsonObject {
|
||||
// Rows enqueued under the pre-hello "main" alias must flush to the canonical main
|
||||
// session the gateway announced, matching how the UI attributes those rows.
|
||||
put("sessionKey", JsonPrimitive(normalizeRequestedSessionKey(item.sessionKey)))
|
||||
put("sessionKey", JsonPrimitive(queuedSessionKey))
|
||||
put("message", JsonPrimitive(item.text))
|
||||
// Enqueue-time thinking level: a later selector change must not alter queued sends.
|
||||
put("thinking", JsonPrimitive(item.thinkingLevel))
|
||||
put("thinking", JsonPrimitive(thinking))
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
// The row id is the idempotency key, so gateway-side dedupe makes redelivery of an
|
||||
// acked-but-crashed item harmless.
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ internal fun ChatComposer(
|
|||
draftText: ChatDraft?,
|
||||
healthOk: Boolean,
|
||||
thinkingLevel: String,
|
||||
thinkingSupported: Boolean,
|
||||
pendingRunCount: Int,
|
||||
commands: List<ChatCommandEntry>,
|
||||
attachments: List<PendingAttachment>,
|
||||
|
|
@ -174,6 +175,10 @@ internal fun ChatComposer(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(thinkingSupported) {
|
||||
if (!thinkingSupported) showThinkingMenu = false
|
||||
}
|
||||
|
||||
// One in-flight run owns the composer actions; attachments alone are enough to send when the
|
||||
// gateway is healthy. Offline, only text can be sent (it is queued durably; text-only v1).
|
||||
val canSend =
|
||||
|
|
@ -239,44 +244,46 @@ internal fun ChatComposer(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box {
|
||||
Surface(
|
||||
onClick = { showThinkingMenu = true },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileCardSurface,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
if (thinkingSupported) {
|
||||
Box {
|
||||
Surface(
|
||||
onClick = { showThinkingMenu = true },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileCardSurface,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Text(
|
||||
text = thinkingLabel(thinkingLevel),
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = "Select thinking level",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = mobileTextTertiary,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = thinkingLabel(thinkingLevel),
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = "Select thinking level",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = mobileTextTertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showThinkingMenu,
|
||||
onDismissRequest = { showThinkingMenu = false },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
containerColor = mobileCardSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 8.dp,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
DropdownMenu(
|
||||
expanded = showThinkingMenu,
|
||||
onDismissRequest = { showThinkingMenu = false },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
containerColor = mobileCardSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 8.dp,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@ internal fun GatewayModelSummary.providerQualifiedRef(): String {
|
|||
return if (id.startsWith(providerPrefix)) id else "$providerPrefix$id"
|
||||
}
|
||||
|
||||
internal fun thinkingSupportedForSelection(
|
||||
selectedModelRef: String?,
|
||||
catalog: List<GatewayModelSummary>,
|
||||
): Boolean {
|
||||
val selected = selectedModelRef ?: return true
|
||||
return catalog.firstOrNull { it.providerQualifiedRef() == selected }?.supportsReasoning != false
|
||||
}
|
||||
|
||||
internal fun chatModelPickerSections(
|
||||
catalog: List<GatewayModelSummary>,
|
||||
favorites: List<String>,
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ fun ChatScreen(
|
|||
val micCooldown by viewModel.micCooldown.collectAsState()
|
||||
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
|
||||
val talkModeListening by viewModel.talkModeListening.collectAsState()
|
||||
val thinkingSupported = thinkingSupportedForSelection(selectedModelRef, modelCatalog)
|
||||
val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions)
|
||||
val gatewayAddress = gatewayDiagnosticsEndpoint(remoteAddress = remoteAddress, manualHost = manualHost, manualPort = manualPort, manualTls = manualTls)
|
||||
val gatewayProblemMessage = gatewayConnectionDisplay.problem?.message?.takeIf { it.isNotBlank() }
|
||||
|
|
@ -321,6 +322,7 @@ fun ChatScreen(
|
|||
onValueChange = { input = it },
|
||||
attachments = attachments,
|
||||
thinkingLevel = thinkingLevel,
|
||||
thinkingSupported = thinkingSupported,
|
||||
contextUsage = contextUsage,
|
||||
selectedModelLabel = selectedModelLabel,
|
||||
modelPickerEnabled = gatewayConnectionDisplay.isConnected,
|
||||
|
|
@ -1038,6 +1040,7 @@ private fun ChatComposer(
|
|||
onValueChange: (String) -> Unit,
|
||||
attachments: List<PendingAttachment>,
|
||||
thinkingLevel: String,
|
||||
thinkingSupported: Boolean,
|
||||
contextUsage: ChatContextUsage,
|
||||
selectedModelLabel: String,
|
||||
modelPickerEnabled: Boolean,
|
||||
|
|
@ -1085,6 +1088,7 @@ private fun ChatComposer(
|
|||
)
|
||||
ChatContextMeter(
|
||||
thinkingLevel = thinkingLevel,
|
||||
thinkingSupported = thinkingSupported,
|
||||
contextUsage = contextUsage,
|
||||
onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) },
|
||||
)
|
||||
|
|
@ -1388,6 +1392,7 @@ private fun ChatOfflineNotice(
|
|||
@Composable
|
||||
private fun ChatContextMeter(
|
||||
thinkingLevel: String,
|
||||
thinkingSupported: Boolean,
|
||||
contextUsage: ChatContextUsage,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
|
|
@ -1399,6 +1404,7 @@ private fun ChatContextMeter(
|
|||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
enabled = thinkingSupported,
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
|
|
@ -1409,9 +1415,11 @@ private fun ChatContextMeter(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle)
|
||||
if (thinkingSupported) {
|
||||
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
Text(
|
||||
text = contextMeterLabel(contextUsage, thinkingLevel),
|
||||
text = contextMeterLabel(contextUsage, thinkingLevel, thinkingSupported),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
|
|
@ -1658,9 +1666,10 @@ internal fun contextMeterWidth(usage: ChatContextUsage): Float? {
|
|||
internal fun contextMeterLabel(
|
||||
usage: ChatContextUsage,
|
||||
thinkingLevel: String,
|
||||
thinkingSupported: Boolean = true,
|
||||
): String {
|
||||
val contextLabel = contextMeterWidth(usage)?.let { "Context ${(it * 100).roundToInt()}%" } ?: "Context --"
|
||||
return "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}"
|
||||
return if (thinkingSupported) "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}" else contextLabel
|
||||
}
|
||||
|
||||
internal fun contextMeterThinkingLabel(value: String): String =
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
||||
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
|
||||
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
||||
val selectedModelRef by viewModel.chatSelectedModelRef.collectAsState()
|
||||
// Gate from the controller's agent-scoped catalog — the same source the send gate reads —
|
||||
// so the visible control can never disagree with what sends actually carry.
|
||||
val modelCatalog by viewModel.chatModelCatalog.collectAsState()
|
||||
val thinkingSupported = thinkingSupportedForSelection(selectedModelRef, modelCatalog)
|
||||
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
|
|
@ -204,6 +209,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||
draftText = chatDraft,
|
||||
healthOk = healthOk,
|
||||
thinkingLevel = thinkingLevel,
|
||||
thinkingSupported = thinkingSupported,
|
||||
pendingRunCount = pendingRunCount,
|
||||
commands = chatCommands,
|
||||
attachments = attachments,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import kotlinx.coroutines.test.advanceUntilIdle
|
|||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
|
|
@ -333,4 +335,65 @@ class ChatControllerModelSelectionTest {
|
|||
assertEquals(2, metadataRequests)
|
||||
assertTrue(controller.modelCatalog.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun unsupportedReasoningSendsOffWithoutChangingStoredLevelAndRestoresAfterFlip() =
|
||||
runTest {
|
||||
val sentThinkingLevels = mutableListOf<String>()
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { method, paramsJson ->
|
||||
when (method) {
|
||||
"chat.send" -> {
|
||||
val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject
|
||||
sentThinkingLevels += (params["thinking"] as JsonPrimitive).content
|
||||
"""{"runId":"run-${sentThinkingLevels.size}","status":"ok"}"""
|
||||
}
|
||||
"chat.history" -> """{"messages":[],"sessionInfo":{"key":"main"}}"""
|
||||
"sessions.list" -> """{"sessions":[]}"""
|
||||
// Gating reads the controller-owned agent-scoped catalog hydrated from chat.metadata.
|
||||
"chat.metadata" ->
|
||||
"""
|
||||
{
|
||||
"commands": [],
|
||||
"models": [
|
||||
{"id": "plain", "name": "plain", "provider": "openai", "available": true, "input": ["text"], "reasoning": false},
|
||||
{"id": "reasoning", "name": "reasoning", "provider": "openai", "available": true, "input": ["text"], "reasoning": true}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
else -> "{}"
|
||||
}
|
||||
},
|
||||
)
|
||||
controller.handleGatewayEvent("health", null)
|
||||
controller.load("main")
|
||||
advanceUntilIdle()
|
||||
controller.setThinkingLevel("high")
|
||||
assertTrue(controller.setSessionModelAwait("main", "openai/plain"))
|
||||
|
||||
assertTrue(
|
||||
controller.sendMessageAwaitAcceptance(
|
||||
message = "plain model",
|
||||
thinkingLevel = controller.thinkingLevel.value,
|
||||
attachments = emptyList(),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("off"), sentThinkingLevels)
|
||||
assertEquals("high", controller.thinkingLevel.value)
|
||||
|
||||
assertTrue(controller.setSessionModelAwait("main", "openai/reasoning"))
|
||||
assertTrue(
|
||||
controller.sendMessageAwaitAcceptance(
|
||||
message = "reasoning restored",
|
||||
thinkingLevel = controller.thinkingLevel.value,
|
||||
attachments = emptyList(),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("off", "high"), sentThinkingLevels)
|
||||
assertEquals("high", controller.thinkingLevel.value)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ class ChatControllerOutboxTest {
|
|||
val sentSessionKeys = mutableListOf<String>()
|
||||
val sentThinkingLevels = mutableListOf<String>()
|
||||
var historyMessagesJson = "[]"
|
||||
var metadataModelsJson = "[]"
|
||||
|
||||
suspend fun request(
|
||||
method: String,
|
||||
|
|
@ -166,6 +167,7 @@ class ChatControllerOutboxTest {
|
|||
sendResponse(key)
|
||||
}
|
||||
"chat.history" -> """{"sessionId":"session-1","messages":$historyMessagesJson}"""
|
||||
"chat.metadata" -> """{"commands":[],"models":$metadataModelsJson}"""
|
||||
else -> "{}"
|
||||
}
|
||||
}
|
||||
|
|
@ -256,6 +258,65 @@ class ChatControllerOutboxTest {
|
|||
assertTrue(chat.outboxItems.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconnectGatesActiveSessionThinkingAndFailsOpenForOtherSessions() =
|
||||
runTest {
|
||||
val gateway = FakeGateway()
|
||||
val outbox = FakeCommandOutbox()
|
||||
val now = System.currentTimeMillis()
|
||||
// Gating reads the controller-owned agent-scoped catalog hydrated from chat.metadata,
|
||||
// so hydrate first (empty queue) and seed the rows afterwards; the flush loop re-reads
|
||||
// the outbox on each health transition.
|
||||
gateway.metadataModelsJson =
|
||||
"""[{"id":"plain","name":"Plain","provider":"openai","available":true,"input":["text"],"reasoning":false}]"""
|
||||
val chat = controller(this, gateway, outbox)
|
||||
gateway.online = true
|
||||
chat.load("main")
|
||||
advanceUntilIdle()
|
||||
|
||||
outbox.seed(
|
||||
ChatOutboxItem(
|
||||
id = "active",
|
||||
sessionKey = "main",
|
||||
text = "active session",
|
||||
thinkingLevel = "high",
|
||||
createdAtMs = now,
|
||||
status = ChatOutboxStatus.Queued,
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
),
|
||||
)
|
||||
outbox.seed(
|
||||
ChatOutboxItem(
|
||||
id = "other",
|
||||
sessionKey = "other-session",
|
||||
text = "unknown session",
|
||||
thinkingLevel = "medium",
|
||||
createdAtMs = now + 1,
|
||||
status = ChatOutboxStatus.Queued,
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
),
|
||||
)
|
||||
assertTrue(chat.setSessionModelAwait("main", "openai/plain"))
|
||||
// Drop health via a transport failure mid-flush: unlike a disconnect this keeps the
|
||||
// hydrated catalog, which is the state where the flush gate has data to act on.
|
||||
gateway.failSendsWithTransportError = true
|
||||
chat.retryOutboxCommand("active")
|
||||
advanceUntilIdle()
|
||||
assertFalse(chat.healthOk.value)
|
||||
|
||||
gateway.failSendsWithTransportError = false
|
||||
chat.handleGatewayEvent("health", null)
|
||||
advanceUntilIdle()
|
||||
|
||||
// retryOutboxCommand refreshes the active row's createdAt, so the untouched
|
||||
// unknown-session row flushes first in createdAt order.
|
||||
assertEquals(listOf("unknown session", "active session"), gateway.sentMessages)
|
||||
assertEquals(listOf("medium", "off"), gateway.sentThinkingLevels)
|
||||
assertTrue(chat.outboxItems.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mainAliasRowsFlushToCanonicalMainSessionAfterHello() =
|
||||
runTest {
|
||||
|
|
|
|||
|
|
@ -81,4 +81,11 @@ class ChatContextMeterTest {
|
|||
assertNull(contextMeterWidth(usage))
|
||||
assertEquals("Context -- · high", contextMeterLabel(usage, "high"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterHidesThinkingLabelWhenUnsupported() {
|
||||
val usage = ChatContextUsage(totalTokens = 2_500L, totalTokensFresh = true, contextTokens = 10_000L)
|
||||
|
||||
assertEquals("Context 25%", contextMeterLabel(usage, "high", thinkingSupported = false))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package ai.openclaw.app.ui.chat
|
|||
|
||||
import ai.openclaw.app.GatewayModelSummary
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatModelPickerTest {
|
||||
|
|
@ -33,9 +35,24 @@ class ChatModelPickerTest {
|
|||
assertEquals(listOf("two/b"), sections.remaining.map { it.providerQualifiedRef() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thinkingSupportFailsOpenUnlessMatchedModelDisablesReasoning() {
|
||||
val catalog =
|
||||
listOf(
|
||||
model(id = "reasoning", provider = "openai", supportsReasoning = true),
|
||||
model(id = "plain", provider = "openai", supportsReasoning = false),
|
||||
)
|
||||
|
||||
assertTrue(thinkingSupportedForSelection(selectedModelRef = null, catalog = catalog))
|
||||
assertTrue(thinkingSupportedForSelection(selectedModelRef = "openai/unknown", catalog = catalog))
|
||||
assertTrue(thinkingSupportedForSelection(selectedModelRef = "openai/reasoning", catalog = catalog))
|
||||
assertFalse(thinkingSupportedForSelection(selectedModelRef = "openai/plain", catalog = catalog))
|
||||
}
|
||||
|
||||
private fun model(
|
||||
id: String,
|
||||
provider: String,
|
||||
supportsReasoning: Boolean = false,
|
||||
): GatewayModelSummary =
|
||||
GatewayModelSummary(
|
||||
id = id,
|
||||
|
|
@ -45,7 +62,7 @@ class ChatModelPickerTest {
|
|||
supportsVision = false,
|
||||
supportsAudio = false,
|
||||
supportsDocuments = false,
|
||||
supportsReasoning = false,
|
||||
supportsReasoning = supportsReasoning,
|
||||
contextTokens = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue