chore: upgrade ktlint to 1.8.0 and format codebase (#1024)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
3f3b853758
commit
cfe3e736fd
66 files changed
+1287
-541
No files matched your search
@@ -52,13 +52,13 @@ jobs:
|
|||||||
uses: actions/cache@v6
|
uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: ~/.local/bin/ktlint
|
path: ~/.local/bin/ktlint
|
||||||
key: ktlint-1.2.1
|
key: ktlint-1.8.0
|
||||||
|
|
||||||
- name: Install ktlint
|
- name: Install ktlint
|
||||||
if: steps.cache-ktlint.outputs.cache-hit != 'true'
|
if: steps.cache-ktlint.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.local/bin
|
mkdir -p ~/.local/bin
|
||||||
curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.2.1/ktlint
|
curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.8.0/ktlint
|
||||||
chmod +x ktlint
|
chmod +x ktlint
|
||||||
mv ktlint ~/.local/bin/
|
mv ktlint ~/.local/bin/
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ If you have a local Android SDK (`ANDROID_HOME` set), these work:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download the matching binary
|
# Download the matching binary
|
||||||
curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.2.1/ktlint
|
curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.8.0/ktlint
|
||||||
chmod +x ktlint
|
chmod +x ktlint
|
||||||
./ktlint <file> # check one file
|
./ktlint <file> # check one file
|
||||||
./ktlint --format <file> # auto-fix
|
./ktlint --format <file> # auto-fix
|
||||||
@@ -48,7 +48,7 @@ chmod +x ktlint
|
|||||||
|
|
||||||
| Job | Purpose |
|
| Job | Purpose |
|
||||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ktlint` | ktlint 1.2.1 style check + `checkColorLiterals` (hardcoded Color guard, issue #622) |
|
| `ktlint` | ktlint 1.8.0 style check + `checkColorLiterals` (hardcoded Color guard, issue #622) |
|
||||||
| `android-lint` | Android Lint |
|
| `android-lint` | Android Lint |
|
||||||
| `unit-tests` | JUnit |
|
| `unit-tests` | JUnit |
|
||||||
| `build` | assembleDebug (gated by the 3 above) |
|
| `build` | assembleDebug (gated by the 3 above) |
|
||||||
@@ -68,7 +68,7 @@ Requires `permissions: contents: write` on the `build-release` job.
|
|||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
- **ktlint 1.2.1** is enforced in CI. Run `./ktlint --format` before pushing.
|
- **ktlint 1.8.0** is enforced in CI. Run `./ktlint --format` before pushing.
|
||||||
- Import ordering is the #1 CI failure: ktlint enforces ASCII-lexicographic order
|
- Import ordering is the #1 CI failure: ktlint enforces ASCII-lexicographic order
|
||||||
(uppercase before lowercase: `LaunchedEffect` before `collectAsState`).
|
(uppercase before lowercase: `LaunchedEffect` before `collectAsState`).
|
||||||
- `const val` must use SCREAMING_SNAKE_CASE.
|
- `const val` must use SCREAMING_SNAKE_CASE.
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices.
|
|||||||
|
|
||||||
### Kotlin Formatting
|
### Kotlin Formatting
|
||||||
|
|
||||||
- Code formatting is checked and enforced by **ktlint 1.2.1** in CI. The authoritative command is the Gradle task (not the standalone binary):
|
- Code formatting is checked and enforced by **ktlint 1.8.0** in CI. The authoritative command is the Gradle task (not the standalone binary):
|
||||||
```bash
|
```bash
|
||||||
./gradlew ktlintCheck # check (CI-equivalent)
|
./gradlew ktlintCheck # check (CI-equivalent)
|
||||||
./gradlew ktlintFormat # auto-fix, then re-check with ktlintCheck
|
./gradlew ktlintFormat # auto-fix, then re-check with ktlintCheck
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ app/src/main/java/com/m57/hermescontrol/
|
|||||||
- **Theming:** 6 built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Neon Noir) + Material You dynamic colors
|
- **Theming:** 6 built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Neon Noir) + Material You dynamic colors
|
||||||
- **Image Loading:** Coil 2.7.0
|
- **Image Loading:** Coil 2.7.0
|
||||||
- **Testing:** JUnit 5, MockK, Turbine, Espresso, Compose UI testing
|
- **Testing:** JUnit 5, MockK, Turbine, Espresso, Compose UI testing
|
||||||
- **Formatting:** `ktlint` 1.2.1 style rules (checked automatically in CI)
|
- **Formatting:** `ktlint` 1.8.0 style rules (checked automatically in CI)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import com.m57.hermescontrol.MainActivity
|
|||||||
* the launch works on every supported API level (the framework's
|
* the launch works on every supported API level (the framework's
|
||||||
* [AssistState] default forwards to it below API 29).
|
* [AssistState] default forwards to it below API 29).
|
||||||
*/
|
*/
|
||||||
class AssistantSession(context: Context) : VoiceInteractionSession(context) {
|
class AssistantSession(
|
||||||
|
context: Context,
|
||||||
|
) : VoiceInteractionSession(context) {
|
||||||
private var launchHandled = false
|
private var launchHandled = false
|
||||||
|
|
||||||
override fun onShow(
|
override fun onShow(
|
||||||
|
|||||||
@@ -239,13 +239,14 @@ data class GroupChatRoomMeta(
|
|||||||
) {
|
) {
|
||||||
val memberNames: List<String>
|
val memberNames: List<String>
|
||||||
get() =
|
get() =
|
||||||
members?.mapNotNull { el ->
|
members
|
||||||
when (el) {
|
?.mapNotNull { el ->
|
||||||
is JsonPrimitive -> el.content
|
when (el) {
|
||||||
is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content
|
is JsonPrimitive -> el.content
|
||||||
else -> null
|
is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content
|
||||||
}
|
else -> null
|
||||||
}.orEmpty()
|
}
|
||||||
|
}.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ object GatewayFileClient {
|
|||||||
token: String,
|
token: String,
|
||||||
path: String,
|
path: String,
|
||||||
): String? {
|
): String? {
|
||||||
val kind = com.m57.hermescontrol.ui.chat.mediaKindForPath(path)
|
val kind =
|
||||||
|
com.m57.hermescontrol.ui.chat
|
||||||
|
.mediaKindForPath(path)
|
||||||
val endpoint =
|
val endpoint =
|
||||||
if (kind == com.m57.hermescontrol.ui.chat.MediaKind.AUDIO ||
|
if (kind == com.m57.hermescontrol.ui.chat.MediaKind.AUDIO ||
|
||||||
kind == com.m57.hermescontrol.ui.chat.MediaKind.VIDEO
|
kind == com.m57.hermescontrol.ui.chat.MediaKind.VIDEO
|
||||||
|
|||||||
@@ -23,11 +23,21 @@ fun isLocalNetworkHost(host: String): Boolean {
|
|||||||
val a = raw[0].toInt() and 0xFF
|
val a = raw[0].toInt() and 0xFF
|
||||||
val b = raw[1].toInt() and 0xFF
|
val b = raw[1].toInt() and 0xFF
|
||||||
return when {
|
return when {
|
||||||
a == 10 -> true // 10.0.0.0/8
|
a == 10 -> true
|
||||||
a == 172 && b in 16..31 -> true // 172.16.0.0/12
|
|
||||||
a == 192 && b == 168 -> true // 192.168.0.0/16
|
// 10.0.0.0/8
|
||||||
a == 169 && b == 254 -> true // 169.254.0.0/16 link-local
|
a == 172 && b in 16..31 -> true
|
||||||
a == 100 && b in 64..127 -> true // 100.64.0.0/10 CGNAT
|
|
||||||
|
// 172.16.0.0/12
|
||||||
|
a == 192 && b == 168 -> true
|
||||||
|
|
||||||
|
// 192.168.0.0/16
|
||||||
|
a == 169 && b == 254 -> true
|
||||||
|
|
||||||
|
// 169.254.0.0/16 link-local
|
||||||
|
a == 100 && b in 64..127 -> true
|
||||||
|
|
||||||
|
// 100.64.0.0/10 CGNAT
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ sealed interface AppUpdateState {
|
|||||||
data object Checking : AppUpdateState
|
data object Checking : AppUpdateState
|
||||||
|
|
||||||
/** Latest release equals the installed version. */
|
/** Latest release equals the installed version. */
|
||||||
data class UpToDate(val latestTag: String) : AppUpdateState
|
data class UpToDate(
|
||||||
|
val latestTag: String,
|
||||||
|
) : AppUpdateState
|
||||||
|
|
||||||
/** A newer release with an APK asset exists. */
|
/** A newer release with an APK asset exists. */
|
||||||
data class UpdateAvailable(
|
data class UpdateAvailable(
|
||||||
@@ -24,15 +26,21 @@ sealed interface AppUpdateState {
|
|||||||
) : AppUpdateState
|
) : AppUpdateState
|
||||||
|
|
||||||
/** APK download in progress; [progress] is 0..1. */
|
/** APK download in progress; [progress] is 0..1. */
|
||||||
data class Downloading(val progress: Float) : AppUpdateState
|
data class Downloading(
|
||||||
|
val progress: Float,
|
||||||
|
) : AppUpdateState
|
||||||
|
|
||||||
/** Download finished and the system package installer was launched. */
|
/** Download finished and the system package installer was launched. */
|
||||||
data class Installing(val latestTag: String) : AppUpdateState
|
data class Installing(
|
||||||
|
val latestTag: String,
|
||||||
|
) : AppUpdateState
|
||||||
|
|
||||||
/** Install-from-unknown-sources not granted for this app yet. */
|
/** Install-from-unknown-sources not granted for this app yet. */
|
||||||
data object NeedsUnknownSourcesPermission : AppUpdateState
|
data object NeedsUnknownSourcesPermission : AppUpdateState
|
||||||
|
|
||||||
data class Error(val message: String) : AppUpdateState
|
data class Error(
|
||||||
|
val message: String,
|
||||||
|
) : AppUpdateState
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The release tag a state carries, when it carries one. */
|
/** The release tag a state carries, when it carries one. */
|
||||||
|
|||||||
@@ -256,12 +256,15 @@ object HermesWsClient {
|
|||||||
is WsEvent.ThinkingDelta,
|
is WsEvent.ThinkingDelta,
|
||||||
is WsEvent.ReasoningDelta,
|
is WsEvent.ReasoningDelta,
|
||||||
is WsEvent.ToolStart,
|
is WsEvent.ToolStart,
|
||||||
-> pendingReply = true
|
-> {
|
||||||
|
pendingReply = true
|
||||||
|
}
|
||||||
|
|
||||||
is WsEvent.MessageComplete -> {
|
is WsEvent.MessageComplete -> {
|
||||||
pendingReply = false
|
pendingReply = false
|
||||||
disconnectIfIdleInBackground()
|
disconnectIfIdleInBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -992,7 +995,9 @@ object HermesWsClient {
|
|||||||
disconnectIfIdleInBackground()
|
disconnectIfIdleInBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> Unit
|
else -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// tryEmit on a DROP_OLDEST flow only returns false when the
|
// tryEmit on a DROP_OLDEST flow only returns false when the
|
||||||
// buffer is full AND no subscriber is draining; with extraBuffer=512
|
// buffer is full AND no subscriber is draining; with extraBuffer=512
|
||||||
|
|||||||
@@ -44,12 +44,30 @@ data class JsonRpcError(
|
|||||||
|
|
||||||
fun Any?.toJsonElement(): JsonElement =
|
fun Any?.toJsonElement(): JsonElement =
|
||||||
when (this) {
|
when (this) {
|
||||||
null -> JsonNull
|
null -> {
|
||||||
is JsonElement -> this
|
JsonNull
|
||||||
is Boolean -> JsonPrimitive(this)
|
}
|
||||||
is Int -> JsonPrimitive(this)
|
|
||||||
is Long -> JsonPrimitive(this)
|
is JsonElement -> {
|
||||||
is Float -> JsonPrimitive(this)
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
is Boolean -> {
|
||||||
|
JsonPrimitive(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Int -> {
|
||||||
|
JsonPrimitive(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Long -> {
|
||||||
|
JsonPrimitive(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Float -> {
|
||||||
|
JsonPrimitive(this)
|
||||||
|
}
|
||||||
|
|
||||||
is Double -> {
|
is Double -> {
|
||||||
// Preserve integer semantics: 177.0 → JsonPrimitive(177) not "177.0"
|
// Preserve integer semantics: 177.0 → JsonPrimitive(177) not "177.0"
|
||||||
if (this % 1.0 == 0.0 && this >= Int.MIN_VALUE.toDouble() && this <= Int.MAX_VALUE.toDouble()) {
|
if (this % 1.0 == 0.0 && this >= Int.MIN_VALUE.toDouble() && this <= Int.MAX_VALUE.toDouble()) {
|
||||||
@@ -60,11 +78,26 @@ fun Any?.toJsonElement(): JsonElement =
|
|||||||
JsonPrimitive(this)
|
JsonPrimitive(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is Number -> JsonPrimitive(this)
|
|
||||||
is String -> JsonPrimitive(this)
|
is Number -> {
|
||||||
is Map<*, *> -> JsonObject(this.map { it.key.toString() to it.value.toJsonElement() }.toMap())
|
JsonPrimitive(this)
|
||||||
is List<*> -> JsonArray(this.map { it.toJsonElement() })
|
}
|
||||||
else -> JsonPrimitive(this.toString())
|
|
||||||
|
is String -> {
|
||||||
|
JsonPrimitive(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Map<*, *> -> {
|
||||||
|
JsonObject(this.map { it.key.toString() to it.value.toJsonElement() }.toMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
is List<*> -> {
|
||||||
|
JsonArray(this.map { it.toJsonElement() })
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
JsonPrimitive(this.toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun JsonElement.toAny(): Any? =
|
fun JsonElement.toAny(): Any? =
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ active preset via `LocalHermesStatusColors`.
|
|||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- ktlint 1.2.1 is enforced in CI. Run `./gradlew ktlintCheck` before committing
|
- ktlint 1.8.0 is enforced in CI. Run `./gradlew ktlintCheck` before committing
|
||||||
(the gradle task is the gate, not the standalone `ktlint` binary).
|
(the gradle task is the gate, not the standalone `ktlint` binary).
|
||||||
- Import order is ASCII-lexicographic (uppercase before lowercase).
|
- Import order is ASCII-lexicographic (uppercase before lowercase).
|
||||||
- Every change goes through a PR — never push directly to `main`.
|
- Every change goes through a PR — never push directly to `main`.
|
||||||
@@ -62,7 +62,12 @@ data class BotsUiState(
|
|||||||
profiles.find { it.is_default == true || it.name == "default" }
|
profiles.find { it.is_default == true || it.name == "default" }
|
||||||
?: profiles.firstOrNull()
|
?: profiles.firstOrNull()
|
||||||
val syncSnapshot = defaultProfile?.groupChatSyncSnapshot()
|
val syncSnapshot = defaultProfile?.groupChatSyncSnapshot()
|
||||||
val deletedKeys = syncSnapshot?.deleted?.keys.orEmpty().map { it.lowercase() }
|
val deletedKeys =
|
||||||
|
syncSnapshot
|
||||||
|
?.deleted
|
||||||
|
?.keys
|
||||||
|
.orEmpty()
|
||||||
|
.map { it.lowercase() }
|
||||||
|
|
||||||
val groupNameMap = linkedMapOf<String, String>()
|
val groupNameMap = linkedMapOf<String, String>()
|
||||||
|
|
||||||
@@ -101,61 +106,63 @@ data class BotsUiState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return groupNameMap.values.map { gName ->
|
return groupNameMap.values
|
||||||
val lower = gName.lowercase()
|
.map { gName ->
|
||||||
val matchingRoom =
|
val lower = gName.lowercase()
|
||||||
snapshotRooms[gName]
|
val matchingRoom =
|
||||||
?: snapshotRooms["name:$gName"]
|
snapshotRooms[gName]
|
||||||
?: snapshotRooms["id:$gName"]
|
?: snapshotRooms["name:$gName"]
|
||||||
?: snapshotRooms.values.find { it.name.equals(gName, ignoreCase = true) }
|
?: snapshotRooms["id:$gName"]
|
||||||
|
?: snapshotRooms.values.find { it.name.equals(gName, ignoreCase = true) }
|
||||||
|
|
||||||
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
|
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
|
||||||
|
|
||||||
val matchedProfiles =
|
val matchedProfiles =
|
||||||
profiles.filter { profile ->
|
profiles
|
||||||
val botGroups = profile.botMeta()?.allGroups.orEmpty()
|
.filter { profile ->
|
||||||
botGroups.any { it.equals(gName, ignoreCase = true) } ||
|
val botGroups = profile.botMeta()?.allGroups.orEmpty()
|
||||||
roomMemberNames.contains(profile.name.lowercase()) ||
|
botGroups.any { it.equals(gName, ignoreCase = true) } ||
|
||||||
roomMemberNames.contains(profile.effectiveTitle.lowercase())
|
roomMemberNames.contains(profile.name.lowercase()) ||
|
||||||
}.toMutableList()
|
roomMemberNames.contains(profile.effectiveTitle.lowercase())
|
||||||
|
}.toMutableList()
|
||||||
|
|
||||||
// If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated
|
// If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated
|
||||||
if (matchingRoom != null && roomMemberNames.isNotEmpty()) {
|
if (matchingRoom != null && roomMemberNames.isNotEmpty()) {
|
||||||
for (mName in matchingRoom.memberNames) {
|
for (mName in matchingRoom.memberNames) {
|
||||||
val exists =
|
val exists =
|
||||||
matchedProfiles.any {
|
matchedProfiles.any {
|
||||||
it.name.equals(mName, ignoreCase = true) ||
|
it.name.equals(mName, ignoreCase = true) ||
|
||||||
it.effectiveTitle.equals(mName, ignoreCase = true)
|
it.effectiveTitle.equals(mName, ignoreCase = true)
|
||||||
|
}
|
||||||
|
if (!exists) {
|
||||||
|
val local = profiles.find { it.name.equals(mName, ignoreCase = true) }
|
||||||
|
matchedProfiles.add(local ?: ProfileInfo(name = mName))
|
||||||
}
|
}
|
||||||
if (!exists) {
|
|
||||||
val local = profiles.find { it.name.equals(mName, ignoreCase = true) }
|
|
||||||
matchedProfiles.add(local ?: ProfileInfo(name = mName))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var groupMembers: List<ProfileInfo> = matchedProfiles
|
var groupMembers: List<ProfileInfo> = matchedProfiles
|
||||||
|
|
||||||
// Fallback: If group name was composed from bot names (e.g. "default, scoutbot")
|
// Fallback: If group name was composed from bot names (e.g. "default, scoutbot")
|
||||||
if (groupMembers.isEmpty() && gName.contains(",")) {
|
if (groupMembers.isEmpty() && gName.contains(",")) {
|
||||||
val targetNames = gName.split(",").map { it.trim().lowercase() }
|
val targetNames = gName.split(",").map { it.trim().lowercase() }
|
||||||
groupMembers = profiles.filter { targetNames.contains(it.name.lowercase()) }
|
groupMembers = profiles.filter { targetNames.contains(it.name.lowercase()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val lastAt =
|
val lastAt =
|
||||||
matchingRoom?.log?.lastOrNull()?.at
|
matchingRoom?.log?.lastOrNull()?.at
|
||||||
?: matchingRoom?.updatedAt
|
?: matchingRoom?.updatedAt
|
||||||
?: 0L
|
?: 0L
|
||||||
|
|
||||||
GroupInfo(
|
GroupInfo(
|
||||||
name = gName,
|
name = gName,
|
||||||
members = groupMembers,
|
members = groupMembers,
|
||||||
lastActivity = lastAt,
|
lastActivity = lastAt,
|
||||||
|
)
|
||||||
|
}.sortedWith(
|
||||||
|
compareByDescending<GroupInfo> { it.lastActivity }
|
||||||
|
.thenBy { it.name },
|
||||||
)
|
)
|
||||||
}.sortedWith(
|
|
||||||
compareByDescending<GroupInfo> { it.lastActivity }
|
|
||||||
.thenBy { it.name },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val displayGroups: List<GroupInfo>
|
val displayGroups: List<GroupInfo>
|
||||||
@@ -193,15 +200,13 @@ data class BotsUiState(
|
|||||||
profile.name.lowercase().contains(query) ||
|
profile.name.lowercase().contains(query) ||
|
||||||
profile.effectiveTitle.lowercase().contains(query) ||
|
profile.effectiveTitle.lowercase().contains(query) ||
|
||||||
profile.effectiveDescription.lowercase().contains(query)
|
profile.effectiveDescription.lowercase().contains(query)
|
||||||
}
|
}.sortedWith(
|
||||||
.sortedWith(
|
|
||||||
compareByDescending<ProfileInfo> { it.name == activeProfileName }
|
compareByDescending<ProfileInfo> { it.name == activeProfileName }
|
||||||
.thenByDescending {
|
.thenByDescending {
|
||||||
it.canonical_session?.last_active
|
it.canonical_session?.last_active
|
||||||
?: it.last_session?.last_active
|
?: it.last_session?.last_active
|
||||||
?: 0.0
|
?: 0.0
|
||||||
}
|
}.thenBy { it.name },
|
||||||
.thenBy { it.name },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -464,13 +469,14 @@ class BotsViewModel(
|
|||||||
rooms = updatedRooms,
|
rooms = updatedRooms,
|
||||||
deleted = updatedDeleted,
|
deleted = updatedDeleted,
|
||||||
)
|
)
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
WsMethods.PROFILES_CONFIGURE,
|
.request(
|
||||||
mapOf(
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
"name" to defaultProfile.name,
|
mapOf(
|
||||||
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
|
"name" to defaultProfile.name,
|
||||||
),
|
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
|
||||||
).await()
|
),
|
||||||
|
).await()
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
@@ -528,13 +534,14 @@ class BotsViewModel(
|
|||||||
rooms = updatedRooms,
|
rooms = updatedRooms,
|
||||||
deleted = deletedMap,
|
deleted = deletedMap,
|
||||||
)
|
)
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
WsMethods.PROFILES_CONFIGURE,
|
.request(
|
||||||
mapOf(
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
"name" to defaultProfile.name,
|
mapOf(
|
||||||
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
|
"name" to defaultProfile.name,
|
||||||
),
|
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
|
||||||
).await()
|
),
|
||||||
|
).await()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
@@ -100,8 +100,7 @@ fun CreateGroupChatDialog(
|
|||||||
} else {
|
} else {
|
||||||
selectedBots + bot.name
|
selectedBots + bot.name
|
||||||
}
|
}
|
||||||
}
|
}.padding(vertical = 4.dp),
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
|
|||||||
@@ -303,12 +303,13 @@ class GroupChatViewModel(
|
|||||||
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
|
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
|
||||||
|
|
||||||
val matchedProfiles =
|
val matchedProfiles =
|
||||||
allProfiles.filter {
|
allProfiles
|
||||||
val botGroups = it.botMeta()?.allGroups.orEmpty()
|
.filter {
|
||||||
botGroups.any { g -> g.equals(groupName, ignoreCase = true) } ||
|
val botGroups = it.botMeta()?.allGroups.orEmpty()
|
||||||
roomMemberNames.contains(it.name.lowercase()) ||
|
botGroups.any { g -> g.equals(groupName, ignoreCase = true) } ||
|
||||||
roomMemberNames.contains(it.effectiveTitle.lowercase())
|
roomMemberNames.contains(it.name.lowercase()) ||
|
||||||
}.toMutableList()
|
roomMemberNames.contains(it.effectiveTitle.lowercase())
|
||||||
|
}.toMutableList()
|
||||||
|
|
||||||
// If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated
|
// If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated
|
||||||
if (matchingRoom != null && roomMemberNames.isNotEmpty()) {
|
if (matchingRoom != null && roomMemberNames.isNotEmpty()) {
|
||||||
@@ -350,7 +351,11 @@ class GroupChatViewModel(
|
|||||||
initialMessages =
|
initialMessages =
|
||||||
syncedLog.map { entry ->
|
syncedLog.map { entry ->
|
||||||
val isUser = entry.from?.kind != "member"
|
val isUser = entry.from?.kind != "member"
|
||||||
val senderName = entry.from?.name.orEmpty().ifBlank { if (isUser) "user" else "bot" }
|
val senderName =
|
||||||
|
entry.from
|
||||||
|
?.name
|
||||||
|
.orEmpty()
|
||||||
|
.ifBlank { if (isUser) "user" else "bot" }
|
||||||
val memberProfile =
|
val memberProfile =
|
||||||
allProfiles.find {
|
allProfiles.find {
|
||||||
it.name.equals(senderName, ignoreCase = true) ||
|
it.name.equals(senderName, ignoreCase = true) ||
|
||||||
@@ -465,13 +470,14 @@ class GroupChatViewModel(
|
|||||||
|
|
||||||
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
|
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
|
||||||
|
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
WsMethods.PROFILES_CONFIGURE,
|
.request(
|
||||||
mapOf(
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
"name" to defaultProfile.name,
|
mapOf(
|
||||||
"ui_meta" to uiMetaPayload,
|
"name" to defaultProfile.name,
|
||||||
),
|
"ui_meta" to uiMetaPayload,
|
||||||
).await()
|
),
|
||||||
|
).await()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}")
|
Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -559,13 +565,14 @@ class GroupChatViewModel(
|
|||||||
val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms)
|
val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms)
|
||||||
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
|
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
|
||||||
|
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
WsMethods.PROFILES_CONFIGURE,
|
.request(
|
||||||
mapOf(
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
"name" to defaultProfile.name,
|
mapOf(
|
||||||
"ui_meta" to uiMetaPayload,
|
"name" to defaultProfile.name,
|
||||||
),
|
"ui_meta" to uiMetaPayload,
|
||||||
).await()
|
),
|
||||||
|
).await()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -629,10 +636,11 @@ class GroupChatViewModel(
|
|||||||
storedId?.let { inFlightTurns[it] = turnDeferred }
|
storedId?.let { inFlightTurns[it] = turnDeferred }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
WsMethods.PROMPT_SUBMIT,
|
.request(
|
||||||
mapOf("session_id" to runtimeId, "text" to prompt),
|
WsMethods.PROMPT_SUBMIT,
|
||||||
).await()
|
mapOf("session_id" to runtimeId, "text" to prompt),
|
||||||
|
).await()
|
||||||
} catch (submitErr: Exception) {
|
} catch (submitErr: Exception) {
|
||||||
Log.w(
|
Log.w(
|
||||||
"GroupChatViewModel",
|
"GroupChatViewModel",
|
||||||
@@ -662,10 +670,11 @@ class GroupChatViewModel(
|
|||||||
storedId?.let { inFlightTurns[it] = turnDeferred }
|
storedId?.let { inFlightTurns[it] = turnDeferred }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
WsMethods.PROMPT_SUBMIT,
|
.request(
|
||||||
mapOf("session_id" to runtimeId, "text" to prompt),
|
WsMethods.PROMPT_SUBMIT,
|
||||||
).await()
|
mapOf("session_id" to runtimeId, "text" to prompt),
|
||||||
|
).await()
|
||||||
} catch (retryErr: Exception) {
|
} catch (retryErr: Exception) {
|
||||||
Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}")
|
Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}")
|
||||||
return null
|
return null
|
||||||
@@ -880,7 +889,10 @@ class GroupChatViewModel(
|
|||||||
|
|
||||||
val bot = remainingInitial.removeAt(0)
|
val bot = remainingInitial.removeAt(0)
|
||||||
val reply = executeBotTurn(bot, epoch, members)
|
val reply = executeBotTurn(bot, epoch, members)
|
||||||
val nonSystemSize = _uiState.value.messages.filter { !it.isSystem }.size
|
val nonSystemSize =
|
||||||
|
_uiState.value.messages
|
||||||
|
.filter { !it.isSystem }
|
||||||
|
.size
|
||||||
memberWatermarks[bot.name] = nonSystemSize
|
memberWatermarks[bot.name] = nonSystemSize
|
||||||
|
|
||||||
if (reply != null) {
|
if (reply != null) {
|
||||||
@@ -931,7 +943,10 @@ class GroupChatViewModel(
|
|||||||
|
|
||||||
val bot = continuationResponders.removeAt(0)
|
val bot = continuationResponders.removeAt(0)
|
||||||
val reply = executeBotTurn(bot, epoch, members)
|
val reply = executeBotTurn(bot, epoch, members)
|
||||||
val nonSystemSize = _uiState.value.messages.filter { !it.isSystem }.size
|
val nonSystemSize =
|
||||||
|
_uiState.value.messages
|
||||||
|
.filter { !it.isSystem }
|
||||||
|
.size
|
||||||
memberWatermarks[bot.name] = nonSystemSize
|
memberWatermarks[bot.name] = nonSystemSize
|
||||||
|
|
||||||
if (reply != null) {
|
if (reply != null) {
|
||||||
@@ -1055,13 +1070,14 @@ class GroupChatViewModel(
|
|||||||
|
|
||||||
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
|
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
|
||||||
|
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
WsMethods.PROFILES_CONFIGURE,
|
.request(
|
||||||
mapOf(
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
"name" to defaultProfile.name,
|
mapOf(
|
||||||
"ui_meta" to uiMetaPayload,
|
"name" to defaultProfile.name,
|
||||||
),
|
"ui_meta" to uiMetaPayload,
|
||||||
).await()
|
),
|
||||||
|
).await()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}")
|
Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,20 @@ fun toolCallMilestones(messages: List<ChatMessage>): Map<Int, Int> {
|
|||||||
var toolCount = 0
|
var toolCount = 0
|
||||||
messages.forEachIndexed { index, message ->
|
messages.forEachIndexed { index, message ->
|
||||||
when (message.role) {
|
when (message.role) {
|
||||||
MessageRole.USER -> toolCount = 0
|
MessageRole.USER -> {
|
||||||
|
toolCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
MessageRole.TOOL -> {
|
MessageRole.TOOL -> {
|
||||||
toolCount += 1
|
toolCount += 1
|
||||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||||
milestones[index] = toolCount
|
milestones[index] = toolCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> Unit
|
|
||||||
|
else -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return milestones
|
return milestones
|
||||||
|
|||||||
@@ -180,15 +180,27 @@ class ChatMediaDelegate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is GatewayFileResult.NotFound -> showOpenError("File not found on gateway: ${attachment.name}")
|
is GatewayFileResult.NotFound -> {
|
||||||
is GatewayFileResult.Forbidden -> showOpenError("Access denied: ${attachment.name}")
|
showOpenError("File not found on gateway: ${attachment.name}")
|
||||||
is GatewayFileResult.TooLarge -> showOpenError("File too large to save: ${attachment.name}")
|
}
|
||||||
is GatewayFileResult.Unauthorized ->
|
|
||||||
|
is GatewayFileResult.Forbidden -> {
|
||||||
|
showOpenError("Access denied: ${attachment.name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
is GatewayFileResult.TooLarge -> {
|
||||||
|
showOpenError("File too large to save: ${attachment.name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
is GatewayFileResult.Unauthorized -> {
|
||||||
showOpenError(
|
showOpenError(
|
||||||
"Session expired — reconnect to save: ${attachment.name}",
|
"Session expired — reconnect to save: ${attachment.name}",
|
||||||
)
|
)
|
||||||
is GatewayFileResult.Failure ->
|
}
|
||||||
|
|
||||||
|
is GatewayFileResult.Failure -> {
|
||||||
showOpenError("Could not save ${attachment.name}: ${result.throwable.message}")
|
showOpenError("Could not save ${attachment.name}: ${result.throwable.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
uiState.update {
|
uiState.update {
|
||||||
|
|||||||
@@ -611,12 +611,13 @@ fun ChatScreen(
|
|||||||
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
|
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
|
||||||
onOpenSettings = {
|
onOpenSettings = {
|
||||||
val intent =
|
val intent =
|
||||||
android.content.Intent(
|
android.content
|
||||||
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
.Intent(
|
||||||
android.net.Uri.parse("package:${context.packageName}"),
|
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
).apply {
|
android.net.Uri.parse("package:${context.packageName}"),
|
||||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
).apply {
|
||||||
}
|
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -79,10 +79,14 @@ internal fun canonicalToolResultKey(content: String): String? {
|
|||||||
|
|
||||||
fun canon(e: kotlinx.serialization.json.JsonElement): String =
|
fun canon(e: kotlinx.serialization.json.JsonElement): String =
|
||||||
when (e) {
|
when (e) {
|
||||||
is kotlinx.serialization.json.JsonObject ->
|
is kotlinx.serialization.json.JsonObject -> {
|
||||||
e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" }
|
e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" }
|
||||||
is kotlinx.serialization.json.JsonArray ->
|
}
|
||||||
|
|
||||||
|
is kotlinx.serialization.json.JsonArray -> {
|
||||||
e.joinToString(",") { canon(it) }
|
e.joinToString(",") { canon(it) }
|
||||||
|
}
|
||||||
|
|
||||||
is kotlinx.serialization.json.JsonPrimitive -> {
|
is kotlinx.serialization.json.JsonPrimitive -> {
|
||||||
// Canonicalize ALL numbers through double, collapsing int/float
|
// Canonicalize ALL numbers through double, collapsing int/float
|
||||||
// spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5").
|
// spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5").
|
||||||
@@ -96,9 +100,13 @@ internal fun canonicalToolResultKey(content: String): String? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return when (element) {
|
return when (element) {
|
||||||
is kotlinx.serialization.json.JsonObject ->
|
is kotlinx.serialization.json.JsonObject -> {
|
||||||
element["result"]?.let { canon(it) } ?: canon(element)
|
element["result"]?.let { canon(it) } ?: canon(element)
|
||||||
else -> canon(element)
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
canon(element)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,8 +186,7 @@ internal fun stripAttachmentRefLines(content: String): String =
|
|||||||
line.startsWith("@image:") ||
|
line.startsWith("@image:") ||
|
||||||
line.startsWith("@file:") ||
|
line.startsWith("@file:") ||
|
||||||
line == "[screenshot]"
|
line == "[screenshot]"
|
||||||
}
|
}.joinToString("\n")
|
||||||
.joinToString("\n")
|
|
||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -764,7 +771,9 @@ class ChatViewModel(
|
|||||||
streamingController.flushPendingTokens()
|
streamingController.flushPendingTokens()
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> Unit
|
else -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, let the reducer compute the new state and any effects
|
// First, let the reducer compute the new state and any effects
|
||||||
@@ -2168,7 +2177,10 @@ class ChatViewModel(
|
|||||||
sessionHasServerPresence = true
|
sessionHasServerPresence = true
|
||||||
sessionGoneRecoveryInFlight = false
|
sessionGoneRecoveryInFlight = false
|
||||||
pendingGoneSessionNotice = false
|
pendingGoneSessionNotice = false
|
||||||
val title = _uiState.value.sessions.find { it.id == sessionId }?.title ?: "Hermes"
|
val title =
|
||||||
|
_uiState.value.sessions
|
||||||
|
.find { it.id == sessionId }
|
||||||
|
?.title ?: "Hermes"
|
||||||
val generation = resetSessionState(sessionId, title, isLoading = true)
|
val generation = resetSessionState(sessionId, title, isLoading = true)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Warm-cache fast-path (desktop parity): paint the cached Room
|
// Warm-cache fast-path (desktop parity): paint the cached Room
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import kotlinx.serialization.json.JsonArray
|
|||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
private fun List<ChatMessage>.upsertById(message: ChatMessage): List<ChatMessage> {
|
private fun List<ChatMessage>.upsertById(message: ChatMessage): List<ChatMessage> = (this + message).dedupeById()
|
||||||
return (this + message).dedupeById()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips this turn's sealed-orphan prefix from the final message text.
|
* Strips this turn's sealed-orphan prefix from the final message text.
|
||||||
@@ -943,15 +941,16 @@ fun extractTodosFromJson(content: String): List<TodoItem>? {
|
|||||||
?: ((obj["parameters"] as? JsonObject)?.get("todos") as? JsonArray)
|
?: ((obj["parameters"] as? JsonObject)?.get("todos") as? JsonArray)
|
||||||
?: return null
|
?: return null
|
||||||
|
|
||||||
todosArray.mapNotNull { item ->
|
todosArray
|
||||||
val itemObj = item as? JsonObject ?: return@mapNotNull null
|
.mapNotNull { item ->
|
||||||
val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null
|
val itemObj = item as? JsonObject ?: return@mapNotNull null
|
||||||
val contentStr =
|
val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null
|
||||||
(itemObj["content"] as? JsonPrimitive)?.content
|
val contentStr =
|
||||||
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
|
(itemObj["content"] as? JsonPrimitive)?.content
|
||||||
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
|
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
|
||||||
TodoItem(id = id, content = contentStr, status = status)
|
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
|
||||||
}.takeIf { it.isNotEmpty() }
|
TodoItem(id = id, content = contentStr, status = status)
|
||||||
|
}.takeIf { it.isNotEmpty() }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -580,9 +580,13 @@ private fun tableTextAlign(align: TableAlign?): TextAlign? =
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal sealed interface InlineMathSegment {
|
internal sealed interface InlineMathSegment {
|
||||||
data class Text(val value: String) : InlineMathSegment
|
data class Text(
|
||||||
|
val value: String,
|
||||||
|
) : InlineMathSegment
|
||||||
|
|
||||||
data class Math(val latex: String) : InlineMathSegment
|
data class Math(
|
||||||
|
val latex: String,
|
||||||
|
) : InlineMathSegment
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */
|
/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */
|
||||||
|
|||||||
@@ -26,11 +26,26 @@ class SlashCommandDispatcher {
|
|||||||
val cmd = parts[0].lowercase()
|
val cmd = parts[0].lowercase()
|
||||||
|
|
||||||
return when (cmd) {
|
return when (cmd) {
|
||||||
"/stop", "/interrupt" -> SlashResult.Interrupt
|
"/stop", "/interrupt" -> {
|
||||||
"/new" -> SlashResult.NewSession
|
SlashResult.Interrupt
|
||||||
"/fork", "/branch" -> SlashResult.SessionBranch
|
}
|
||||||
"/model" -> SlashResult.ModelSwitch
|
|
||||||
"/update" -> SlashResult.Update
|
"/new" -> {
|
||||||
|
SlashResult.NewSession
|
||||||
|
}
|
||||||
|
|
||||||
|
"/fork", "/branch" -> {
|
||||||
|
SlashResult.SessionBranch
|
||||||
|
}
|
||||||
|
|
||||||
|
"/model" -> {
|
||||||
|
SlashResult.ModelSwitch
|
||||||
|
}
|
||||||
|
|
||||||
|
"/update" -> {
|
||||||
|
SlashResult.Update
|
||||||
|
}
|
||||||
|
|
||||||
"/queue", "/q" -> {
|
"/queue", "/q" -> {
|
||||||
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
|
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
|
||||||
// Bubble shows the queued TEXT (prefix stripped) so the
|
// Bubble shows the queued TEXT (prefix stripped) so the
|
||||||
@@ -41,8 +56,14 @@ class SlashCommandDispatcher {
|
|||||||
// text so the usage message has something to sit under.
|
// text so the usage message has something to sit under.
|
||||||
SlashResult.QueuePrompt(displayContent = arg.ifBlank { command })
|
SlashResult.QueuePrompt(displayContent = arg.ifBlank { command })
|
||||||
}
|
}
|
||||||
"/resume", "/history" -> SlashResult.OpenHistory
|
|
||||||
else -> SlashResult.RpcDispatch
|
"/resume", "/history" -> {
|
||||||
|
SlashResult.OpenHistory
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
SlashResult.RpcDispatch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,5 +126,7 @@ sealed class SlashResult {
|
|||||||
* argument). Matching the server echo verbatim lets the transcript sync
|
* argument). Matching the server echo verbatim lets the transcript sync
|
||||||
* dedupe the two copies of the same logical message.
|
* dedupe the two copies of the same logical message.
|
||||||
*/
|
*/
|
||||||
data class QueuePrompt(val displayContent: String) : SlashResult()
|
data class QueuePrompt(
|
||||||
|
val displayContent: String,
|
||||||
|
) : SlashResult()
|
||||||
}
|
}
|
||||||
@@ -236,8 +236,7 @@ fun ChatInputBar(
|
|||||||
onInputChange(
|
onInputChange(
|
||||||
ChatInputPolicy.applyMention(inputFieldValue, bot.name),
|
ChatInputPolicy.applyMention(inputFieldValue, bot.name),
|
||||||
)
|
)
|
||||||
}
|
}.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
BotAvatar(
|
BotAvatar(
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ fun parseDiffText(
|
|||||||
line.startsWith("--- ") || line.startsWith("+++ ") -> {
|
line.startsWith("--- ") || line.startsWith("+++ ") -> {
|
||||||
if (extractedPath == null || extractedPath == defaultPath) {
|
if (extractedPath == null || extractedPath == defaultPath) {
|
||||||
val candidate =
|
val candidate =
|
||||||
line.drop(4)
|
line
|
||||||
|
.drop(4)
|
||||||
.trim()
|
.trim()
|
||||||
.removePrefix("a/")
|
.removePrefix("a/")
|
||||||
.removePrefix("b/")
|
.removePrefix("b/")
|
||||||
|
|||||||
@@ -137,8 +137,7 @@ fun ReasoningCard(
|
|||||||
clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText))
|
clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText))
|
||||||
copied = true
|
copied = true
|
||||||
},
|
},
|
||||||
)
|
).testTag("reasoning_card"),
|
||||||
.testTag("reasoning_card"),
|
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
|||||||
+19
-7
@@ -194,15 +194,22 @@ private fun TodoInspectionCard(todo: TodoItem) {
|
|||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val (statusIcon, statusTint) =
|
val (statusIcon, statusTint) =
|
||||||
when {
|
when {
|
||||||
todo.isCompleted ->
|
todo.isCompleted -> {
|
||||||
Icons.Filled.CheckCircle to statusColors.success
|
Icons.Filled.CheckCircle to statusColors.success
|
||||||
todo.isInProgress ->
|
}
|
||||||
|
|
||||||
|
todo.isInProgress -> {
|
||||||
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
|
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
|
||||||
todo.isCancelled ->
|
}
|
||||||
|
|
||||||
|
todo.isCancelled -> {
|
||||||
Icons.Filled.Cancel to statusColors.warning
|
Icons.Filled.Cancel to statusColors.warning
|
||||||
else ->
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
Icons.Filled.RadioButtonUnchecked to
|
Icons.Filled.RadioButtonUnchecked to
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = statusIcon,
|
imageVector = statusIcon,
|
||||||
@@ -242,12 +249,17 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
|
|||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val (statusIcon, statusTint) =
|
val (statusIcon, statusTint) =
|
||||||
when {
|
when {
|
||||||
indicator.isComplete ->
|
indicator.isComplete -> {
|
||||||
Icons.Filled.CheckCircle to statusColors.success
|
Icons.Filled.CheckCircle to statusColors.success
|
||||||
indicator.isFailed ->
|
}
|
||||||
|
|
||||||
|
indicator.isFailed -> {
|
||||||
Icons.Filled.Cancel to statusColors.error
|
Icons.Filled.Cancel to statusColors.error
|
||||||
else ->
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
|
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = statusIcon,
|
imageVector = statusIcon,
|
||||||
|
|||||||
@@ -13,18 +13,28 @@ import com.m57.hermescontrol.ui.chat.MessageRole
|
|||||||
*/
|
*/
|
||||||
sealed interface ChatTurn {
|
sealed interface ChatTurn {
|
||||||
/** User message — always its own turn (bubble anchor). */
|
/** User message — always its own turn (bubble anchor). */
|
||||||
data class User(val message: ChatMessage) : ChatTurn
|
data class User(
|
||||||
|
val message: ChatMessage,
|
||||||
|
) : ChatTurn
|
||||||
|
|
||||||
/** One agent turn: prose, tool rows, and system events in order. */
|
/** One agent turn: prose, tool rows, and system events in order. */
|
||||||
data class Agent(val entries: List<AgentEntry>) : ChatTurn
|
data class Agent(
|
||||||
|
val entries: List<AgentEntry>,
|
||||||
|
) : ChatTurn
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface AgentEntry {
|
sealed interface AgentEntry {
|
||||||
data class Prose(val message: ChatMessage) : AgentEntry
|
data class Prose(
|
||||||
|
val message: ChatMessage,
|
||||||
|
) : AgentEntry
|
||||||
|
|
||||||
data class ToolRow(val message: ChatMessage) : AgentEntry
|
data class ToolRow(
|
||||||
|
val message: ChatMessage,
|
||||||
|
) : AgentEntry
|
||||||
|
|
||||||
data class SystemEvent(val message: ChatMessage) : AgentEntry
|
data class SystemEvent(
|
||||||
|
val message: ChatMessage,
|
||||||
|
) : AgentEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,21 +81,31 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
|||||||
// display_kind (it's stripped on persistence); detect it by its
|
// display_kind (it's stripped on persistence); detect it by its
|
||||||
// stable content prefix and route it as a system event too, tagging
|
// stable content prefix and route it as a system event too, tagging
|
||||||
// it so the timeline chip can name it.
|
// it so the timeline chip can name it.
|
||||||
message.displayKind != null ->
|
message.displayKind != null -> {
|
||||||
agentEntries += AgentEntry.SystemEvent(message)
|
agentEntries += AgentEntry.SystemEvent(message)
|
||||||
|
}
|
||||||
|
|
||||||
message.isSyntheticSystemRow() ->
|
message.isSyntheticSystemRow() -> {
|
||||||
agentEntries +=
|
agentEntries +=
|
||||||
AgentEntry.SystemEvent(message.copy(displayKind = "max_iterations_reached"))
|
AgentEntry.SystemEvent(message.copy(displayKind = "max_iterations_reached"))
|
||||||
|
}
|
||||||
|
|
||||||
message.role == MessageRole.USER -> {
|
message.role == MessageRole.USER -> {
|
||||||
flushAgent()
|
flushAgent()
|
||||||
turns += ChatTurn.User(message)
|
turns += ChatTurn.User(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageRole.ASSISTANT == message.role -> agentEntries += AgentEntry.Prose(message)
|
MessageRole.ASSISTANT == message.role -> {
|
||||||
MessageRole.TOOL == message.role -> agentEntries += AgentEntry.ToolRow(message)
|
agentEntries += AgentEntry.Prose(message)
|
||||||
MessageRole.SYSTEM == message.role -> agentEntries += AgentEntry.SystemEvent(message)
|
}
|
||||||
|
|
||||||
|
MessageRole.TOOL == message.role -> {
|
||||||
|
agentEntries += AgentEntry.ToolRow(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageRole.SYSTEM == message.role -> {
|
||||||
|
agentEntries += AgentEntry.SystemEvent(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flushAgent()
|
flushAgent()
|
||||||
@@ -167,8 +187,13 @@ fun messageIdToLazyIndex(
|
|||||||
itemIndex++
|
itemIndex++
|
||||||
}
|
}
|
||||||
|
|
||||||
is AgentEntry.ToolRow -> itemIndex++
|
is AgentEntry.ToolRow -> {
|
||||||
is AgentEntry.SystemEvent -> itemIndex++
|
itemIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
is AgentEntry.SystemEvent -> {
|
||||||
|
itemIndex++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,8 +218,7 @@ object ToolResultSummary {
|
|||||||
.mapNotNull { k ->
|
.mapNotNull { k ->
|
||||||
val s = summarizeScalar(record[k])
|
val s = summarizeScalar(record[k])
|
||||||
if (s.isEmpty()) null else "${titleCase(k)}: $s"
|
if (s.isEmpty()) null else "${titleCase(k)}: $s"
|
||||||
}
|
}.take(2)
|
||||||
.take(2)
|
|
||||||
|
|
||||||
return if (pairs.isNotEmpty()) pairs.joinToString(" · ") else pluralize(record.keys.size, "field")
|
return if (pairs.isNotEmpty()) pairs.joinToString(" · ") else pluralize(record.keys.size, "field")
|
||||||
}
|
}
|
||||||
@@ -474,12 +473,13 @@ object ToolResultSummary {
|
|||||||
|
|
||||||
if (v is JsonArray) {
|
if (v is JsonArray) {
|
||||||
return clipBlock(
|
return clipBlock(
|
||||||
v.mapNotNull {
|
v
|
||||||
valueErrorText(it).takeIf {
|
.mapNotNull {
|
||||||
s ->
|
valueErrorText(it).takeIf { s ->
|
||||||
s.isNotEmpty()
|
s.isNotEmpty()
|
||||||
}
|
}
|
||||||
}.take(3).joinToString("; "),
|
}.take(3)
|
||||||
|
.joinToString("; "),
|
||||||
700,
|
700,
|
||||||
12,
|
12,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -110,7 +110,10 @@ object ToolViewBuilder {
|
|||||||
|
|
||||||
private fun parseMaybeObject(value: JsonElement?): JsonObject? =
|
private fun parseMaybeObject(value: JsonElement?): JsonObject? =
|
||||||
when (value) {
|
when (value) {
|
||||||
is JsonObject -> value
|
is JsonObject -> {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
is JsonPrimitive -> {
|
is JsonPrimitive -> {
|
||||||
if (!value.isString || value.content.trim().isEmpty()) {
|
if (!value.isString || value.content.trim().isEmpty()) {
|
||||||
null
|
null
|
||||||
@@ -122,7 +125,10 @@ object ToolViewBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> null
|
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unwrapToolPayload(value: JsonElement?): JsonElement? {
|
private fun unwrapToolPayload(value: JsonElement?): JsonElement? {
|
||||||
@@ -459,7 +465,13 @@ object ToolViewBuilder {
|
|||||||
val lines =
|
val lines =
|
||||||
content
|
content
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.mapNotNull { line -> Regex("^(\\d+)\\|").find(line)?.groupValues?.get(1)?.toIntOrNull() }
|
.mapNotNull { line ->
|
||||||
|
Regex("^(\\d+)\\|")
|
||||||
|
.find(line)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
?.toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
if (lines.isNotEmpty()) {
|
if (lines.isNotEmpty()) {
|
||||||
lineLabel =
|
lineLabel =
|
||||||
@@ -659,13 +671,11 @@ object ToolViewBuilder {
|
|||||||
Regex(
|
Regex(
|
||||||
"""\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""",
|
"""\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""",
|
||||||
RegexOption.IGNORE_CASE,
|
RegexOption.IGNORE_CASE,
|
||||||
)
|
).find(t)
|
||||||
.find(t)
|
|
||||||
?: Regex(
|
?: Regex(
|
||||||
"""\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""",
|
"""\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""",
|
||||||
RegexOption.IGNORE_CASE,
|
RegexOption.IGNORE_CASE,
|
||||||
)
|
).find(t)
|
||||||
.find(t)
|
|
||||||
|
|
||||||
val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null
|
val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null
|
||||||
val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun
|
val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun
|
||||||
@@ -715,12 +725,11 @@ object ToolViewBuilder {
|
|||||||
return if (payload === record) emptyList() else collectResultItems(payload)
|
return if (payload === record) emptyList() else collectResultItems(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cleanVisibleText(text: String): String {
|
private fun cleanVisibleText(text: String): String =
|
||||||
return text
|
text
|
||||||
.replace(Regex("`{3,}"), "")
|
.replace(Regex("`{3,}"), "")
|
||||||
.replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "")
|
.replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "")
|
||||||
.replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" }
|
.replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" }
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractSearchResults(
|
private fun extractSearchResults(
|
||||||
result: JsonElement?,
|
result: JsonElement?,
|
||||||
@@ -735,8 +744,7 @@ object ToolViewBuilder {
|
|||||||
url = firstString(r, listOf("url", "href", "link")),
|
url = firstString(r, listOf("url", "href", "link")),
|
||||||
snippet = cleanVisibleText(firstString(r, listOf("snippet", "description", "body"))),
|
snippet = cleanVisibleText(firstString(r, listOf("snippet", "description", "body"))),
|
||||||
)
|
)
|
||||||
}
|
}.filter { it.title.isNotEmpty() || it.url.isNotEmpty() }
|
||||||
.filter { it.title.isNotEmpty() || it.url.isNotEmpty() }
|
|
||||||
.take(limit)
|
.take(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,12 +921,24 @@ object ToolViewBuilder {
|
|||||||
private fun pendingTitle(
|
private fun pendingTitle(
|
||||||
toolName: String,
|
toolName: String,
|
||||||
args: JsonObject?,
|
args: JsonObject?,
|
||||||
): String {
|
): String =
|
||||||
return when (toolName) {
|
when (toolName) {
|
||||||
"web_extract" -> "Reading ${targetHostname(toolName, args, null)}"
|
"web_extract" -> {
|
||||||
"browser_navigate" -> "Opening ${targetHostname(toolName, args, null)}"
|
"Reading ${targetHostname(toolName, args, null)}"
|
||||||
"web_search" -> "Searching \"${compactPreview(searchQueryFor(args), 48)}\""
|
}
|
||||||
"read_file" -> "Reading ${readFileDisplayTarget(args, null)}"
|
|
||||||
|
"browser_navigate" -> {
|
||||||
|
"Opening ${targetHostname(toolName, args, null)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
"web_search" -> {
|
||||||
|
"Searching \"${compactPreview(searchQueryFor(args), 48)}\""
|
||||||
|
}
|
||||||
|
|
||||||
|
"read_file" -> {
|
||||||
|
"Reading ${readFileDisplayTarget(args, null)}"
|
||||||
|
}
|
||||||
|
|
||||||
"terminal", "execute_code" -> {
|
"terminal", "execute_code" -> {
|
||||||
val command = shellCommand(args)
|
val command = shellCommand(args)
|
||||||
val verb = if (toolName == "execute_code") "Running code" else "Running"
|
val verb = if (toolName == "execute_code") "Running code" else "Running"
|
||||||
@@ -931,21 +951,38 @@ object ToolViewBuilder {
|
|||||||
titleForTool(toolName)
|
titleForTool(toolName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"memory" -> "Saving"
|
|
||||||
else -> titleForTool(toolName)
|
"memory" -> {
|
||||||
|
"Saving"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
titleForTool(toolName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun doneTitle(
|
private fun doneTitle(
|
||||||
toolName: String,
|
toolName: String,
|
||||||
args: JsonObject?,
|
args: JsonObject?,
|
||||||
result: JsonObject?,
|
result: JsonObject?,
|
||||||
): String {
|
): String =
|
||||||
return when (toolName) {
|
when (toolName) {
|
||||||
"web_extract" -> "Read ${targetHostname(toolName, args, result)}"
|
"web_extract" -> {
|
||||||
"browser_navigate" -> "Opened ${targetHostname(toolName, args, result)}"
|
"Read ${targetHostname(toolName, args, result)}"
|
||||||
"web_search" -> "Searched \"${compactPreview(searchQueryFor(args), 48)}\""
|
}
|
||||||
"read_file" -> "Read ${readFileDisplayTarget(args, result)}"
|
|
||||||
|
"browser_navigate" -> {
|
||||||
|
"Opened ${targetHostname(toolName, args, result)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
"web_search" -> {
|
||||||
|
"Searched \"${compactPreview(searchQueryFor(args), 48)}\""
|
||||||
|
}
|
||||||
|
|
||||||
|
"read_file" -> {
|
||||||
|
"Read ${readFileDisplayTarget(args, result)}"
|
||||||
|
}
|
||||||
|
|
||||||
"terminal", "execute_code" -> {
|
"terminal", "execute_code" -> {
|
||||||
val command = shellCommand(args)
|
val command = shellCommand(args)
|
||||||
val verb = if (toolName == "execute_code") "Ran code" else "Ran"
|
val verb = if (toolName == "execute_code") "Ran code" else "Ran"
|
||||||
@@ -958,6 +995,7 @@ object ToolViewBuilder {
|
|||||||
titleForTool(toolName)
|
titleForTool(toolName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"memory" -> {
|
"memory" -> {
|
||||||
val action = firstString(args, listOf("action")).lowercase()
|
val action = firstString(args, listOf("action")).lowercase()
|
||||||
val target = firstString(args, listOf("target"))
|
val target = firstString(args, listOf("target"))
|
||||||
@@ -973,27 +1011,35 @@ object ToolViewBuilder {
|
|||||||
// pre-engine mobile summary did.
|
// pre-engine mobile summary did.
|
||||||
if (target.isNotEmpty()) "$base ($target)" else base
|
if (target.isNotEmpty()) "$base ($target)" else base
|
||||||
}
|
}
|
||||||
"cronjob" -> "Cron ${firstString(args, listOf("action")).ifEmpty { "manage" }}"
|
|
||||||
|
"cronjob" -> {
|
||||||
|
"Cron ${firstString(args, listOf("action")).ifEmpty { "manage" }}"
|
||||||
|
}
|
||||||
|
|
||||||
"edit_file", "patch", "write_file" -> {
|
"edit_file", "patch", "write_file" -> {
|
||||||
val path = fileEditPath(args, result)
|
val path = fileEditPath(args, result)
|
||||||
if (path.isNotEmpty()) fileEditBasename(path) else titleForTool(toolName)
|
if (path.isNotEmpty()) fileEditBasename(path) else titleForTool(toolName)
|
||||||
}
|
}
|
||||||
else -> titleForTool(toolName)
|
|
||||||
|
else -> {
|
||||||
|
titleForTool(toolName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun errorTitle(
|
private fun errorTitle(
|
||||||
toolName: String,
|
toolName: String,
|
||||||
result: JsonObject?,
|
result: JsonObject?,
|
||||||
): String {
|
): String =
|
||||||
return when (toolName) {
|
when (toolName) {
|
||||||
"browser_navigate" -> {
|
"browser_navigate" -> {
|
||||||
val url = findFirstUrl(result).ifEmpty { result?.get("url")?.toString() ?: "" }
|
val url = findFirstUrl(result).ifEmpty { result?.get("url")?.toString() ?: "" }
|
||||||
if (url.isNotEmpty()) "Failed to open ${hostnameOf(url)}" else titleForTool(toolName)
|
if (url.isNotEmpty()) "Failed to open ${hostnameOf(url)}" else titleForTool(toolName)
|
||||||
}
|
}
|
||||||
else -> titleForTool(toolName)
|
|
||||||
|
else -> {
|
||||||
|
titleForTool(toolName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun targetHostname(
|
private fun targetHostname(
|
||||||
toolName: String,
|
toolName: String,
|
||||||
@@ -1033,6 +1079,7 @@ object ToolViewBuilder {
|
|||||||
|
|
||||||
if (url.isNotEmpty()) hostnameOf(url) else "Navigated in browser"
|
if (url.isNotEmpty()) hostnameOf(url) else "Navigated in browser"
|
||||||
}
|
}
|
||||||
|
|
||||||
"browser_snapshot" -> {
|
"browser_snapshot" -> {
|
||||||
val snapshot = firstString(result, listOf("snapshot"))
|
val snapshot = firstString(result, listOf("snapshot"))
|
||||||
if (snapshot.isNotEmpty()) {
|
if (snapshot.isNotEmpty()) {
|
||||||
@@ -1043,6 +1090,7 @@ object ToolViewBuilder {
|
|||||||
"Captured a browser accessibility snapshot"
|
"Captured a browser accessibility snapshot"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"browser_click" -> {
|
"browser_click" -> {
|
||||||
val clicked =
|
val clicked =
|
||||||
firstString(
|
firstString(
|
||||||
@@ -1058,21 +1106,24 @@ object ToolViewBuilder {
|
|||||||
"Clicked $clicked"
|
"Clicked $clicked"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"browser_fill", "browser_type" -> {
|
"browser_fill", "browser_type" -> {
|
||||||
val field = firstString(args, listOf("label", "field", "ref", "target"))
|
val field = firstString(args, listOf("label", "field", "ref", "target"))
|
||||||
val value = firstString(args, listOf("value", "text"))
|
val value = firstString(args, listOf("value", "text"))
|
||||||
listOf(
|
listOf(
|
||||||
field.takeIf { it.isNotEmpty() }?.let { "Field: $it" },
|
field.takeIf { it.isNotEmpty() }?.let { "Field: $it" },
|
||||||
value.takeIf { it.isNotEmpty() }?.let { "Value: ${compactPreview(it, 42)}" },
|
value.takeIf { it.isNotEmpty() }?.let { "Value: ${compactPreview(it, 42)}" },
|
||||||
)
|
).filterNotNull()
|
||||||
.filterNotNull()
|
|
||||||
.joinToString(" · ")
|
.joinToString(" · ")
|
||||||
.ifEmpty { "Filled page input" }
|
.ifEmpty { "Filled page input" }
|
||||||
}
|
}
|
||||||
"web_search" ->
|
|
||||||
|
"web_search" -> {
|
||||||
searchQueryFor(
|
searchQueryFor(
|
||||||
args,
|
args,
|
||||||
).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources"
|
).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources"
|
||||||
|
}
|
||||||
|
|
||||||
"terminal", "execute_code" -> {
|
"terminal", "execute_code" -> {
|
||||||
val output = shellOutput(result)
|
val output = shellOutput(result)
|
||||||
val firstMeaningfulLine =
|
val firstMeaningfulLine =
|
||||||
@@ -1089,6 +1140,7 @@ object ToolViewBuilder {
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"read_file", "edit_file", "patch", "write_file" -> {
|
"read_file", "edit_file", "patch", "write_file" -> {
|
||||||
val path =
|
val path =
|
||||||
if (isFileEditTool(toolName)) {
|
if (isFileEditTool(toolName)) {
|
||||||
@@ -1103,6 +1155,7 @@ object ToolViewBuilder {
|
|||||||
fallbackDetailText(args, rawResult)
|
fallbackDetailText(args, rawResult)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"web_extract" -> {
|
"web_extract" -> {
|
||||||
val url =
|
val url =
|
||||||
firstString(args, listOf("url"))
|
firstString(args, listOf("url"))
|
||||||
@@ -1111,11 +1164,27 @@ object ToolViewBuilder {
|
|||||||
|
|
||||||
if (url.isNotEmpty()) hostnameOf(url) else "Fetched webpage"
|
if (url.isNotEmpty()) hostnameOf(url) else "Fetched webpage"
|
||||||
}
|
}
|
||||||
"memory" -> firstString(result, listOf("message", "error"))
|
|
||||||
"cronjob" -> cronjobSubtitle(args, result)
|
"memory" -> {
|
||||||
"todo" -> todoSubtitle(result)
|
firstString(result, listOf("message", "error"))
|
||||||
"fact_store" -> factStoreSubtitle(args, result)
|
}
|
||||||
"session_search" -> sessionSearchSubtitle(result)
|
|
||||||
|
"cronjob" -> {
|
||||||
|
cronjobSubtitle(args, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
"todo" -> {
|
||||||
|
todoSubtitle(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
"fact_store" -> {
|
||||||
|
factStoreSubtitle(args, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
"session_search" -> {
|
||||||
|
sessionSearchSubtitle(result)
|
||||||
|
}
|
||||||
|
|
||||||
"skills_list" -> {
|
"skills_list" -> {
|
||||||
val category = firstString(args, listOf("category"))
|
val category = firstString(args, listOf("category"))
|
||||||
val count =
|
val count =
|
||||||
@@ -1128,17 +1197,23 @@ object ToolViewBuilder {
|
|||||||
"No skills found"
|
"No skills found"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"skill_view" -> firstString(args, listOf("name"))
|
|
||||||
|
"skill_view" -> {
|
||||||
|
firstString(args, listOf("name"))
|
||||||
|
}
|
||||||
|
|
||||||
"skill_manage" -> {
|
"skill_manage" -> {
|
||||||
val action = firstString(args, listOf("action"))
|
val action = firstString(args, listOf("action"))
|
||||||
val name = firstString(args, listOf("name"))
|
val name = firstString(args, listOf("name"))
|
||||||
if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action"
|
if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action"
|
||||||
}
|
}
|
||||||
|
|
||||||
"process" -> {
|
"process" -> {
|
||||||
val action = firstString(args, listOf("action"))
|
val action = firstString(args, listOf("action"))
|
||||||
val procId = firstString(args, listOf("session_id"))
|
val procId = firstString(args, listOf("session_id"))
|
||||||
if (procId.isNotEmpty()) "$action: $procId" else action
|
if (procId.isNotEmpty()) "$action: $procId" else action
|
||||||
}
|
}
|
||||||
|
|
||||||
"x_search" -> {
|
"x_search" -> {
|
||||||
val query = firstString(args, listOf("query"))
|
val query = firstString(args, listOf("query"))
|
||||||
val degraded =
|
val degraded =
|
||||||
@@ -1149,12 +1224,17 @@ object ToolViewBuilder {
|
|||||||
)?.let { !it.isString && it.content == "true" } == true
|
)?.let { !it.isString && it.content == "true" } == true
|
||||||
if (query.isNotEmpty()) "$query${if (degraded) " (no citations)" else ""}" else "Queried X"
|
if (query.isNotEmpty()) "$query${if (degraded) " (no citations)" else ""}" else "Queried X"
|
||||||
}
|
}
|
||||||
"vision_analyze" -> firstString(args, listOf("image_url")).take(60)
|
|
||||||
|
"vision_analyze" -> {
|
||||||
|
firstString(args, listOf("image_url")).take(60)
|
||||||
|
}
|
||||||
|
|
||||||
"tool_search" -> {
|
"tool_search" -> {
|
||||||
val query = firstString(args, listOf("query"))
|
val query = firstString(args, listOf("query"))
|
||||||
val matches = (result?.get("matches") as? JsonArray)?.size
|
val matches = (result?.get("matches") as? JsonArray)?.size
|
||||||
if (matches != null) "$query ($matches matches)" else query
|
if (matches != null) "$query ($matches matches)" else query
|
||||||
}
|
}
|
||||||
|
|
||||||
"image_generate" -> {
|
"image_generate" -> {
|
||||||
val imageUrl = firstString(result, listOf("image")).ifEmpty { firstString(args, listOf("image_url")) }
|
val imageUrl = firstString(result, listOf("image")).ifEmpty { firstString(args, listOf("image_url")) }
|
||||||
val modality = firstString(result, listOf("modality"))
|
val modality = firstString(result, listOf("modality"))
|
||||||
@@ -1162,6 +1242,7 @@ object ToolViewBuilder {
|
|||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.joinToString(" ")
|
.joinToString(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
"project_list" -> {
|
"project_list" -> {
|
||||||
val projects = (result?.get("projects") as? JsonArray)?.size
|
val projects = (result?.get("projects") as? JsonArray)?.size
|
||||||
val active = firstString(result, listOf("active_id"))
|
val active = firstString(result, listOf("active_id"))
|
||||||
@@ -1171,11 +1252,13 @@ object ToolViewBuilder {
|
|||||||
"Projects"
|
"Projects"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"project_create", "project_switch" -> {
|
"project_create", "project_switch" -> {
|
||||||
val actionLabel = if (toolName == "project_create") "Created" else "Switched to"
|
val actionLabel = if (toolName == "project_create") "Created" else "Switched to"
|
||||||
val name = firstString(result, listOf("name"))
|
val name = firstString(result, listOf("name"))
|
||||||
if (name.isNotEmpty()) "$actionLabel $name" else actionLabel
|
if (name.isNotEmpty()) "$actionLabel $name" else actionLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
"read_terminal" -> {
|
"read_terminal" -> {
|
||||||
val error = firstString(result, listOf("error"))
|
val error = firstString(result, listOf("error"))
|
||||||
if (error.isNotEmpty()) {
|
if (error.isNotEmpty()) {
|
||||||
@@ -1193,7 +1276,11 @@ object ToolViewBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"computer_use" -> computerUseSubtitle(args, result)
|
|
||||||
|
"computer_use" -> {
|
||||||
|
computerUseSubtitle(args, result)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val summary = ToolResultSummary.formatToolResultSummary(rawResult)
|
val summary = ToolResultSummary.formatToolResultSummary(rawResult)
|
||||||
val firstLine = summary.split("\n").firstOrNull()?.takeIf { it.isNotEmpty() } ?: ""
|
val firstLine = summary.split("\n").firstOrNull()?.takeIf { it.isNotEmpty() } ?: ""
|
||||||
@@ -1304,9 +1391,13 @@ object ToolViewBuilder {
|
|||||||
60,
|
60,
|
||||||
)}"
|
)}"
|
||||||
} ?: ""}"
|
} ?: ""}"
|
||||||
|
|
||||||
"scroll" -> "${messages?.size ?: 0} messages (scroll)"
|
"scroll" -> "${messages?.size ?: 0} messages (scroll)"
|
||||||
|
|
||||||
"read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}"
|
"read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}"
|
||||||
|
|
||||||
"browse" -> "${count ?: 0} recent sessions"
|
"browse" -> "${count ?: 0} recent sessions"
|
||||||
|
|
||||||
else -> "session_search"
|
else -> "session_search"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1369,6 +1460,7 @@ object ToolViewBuilder {
|
|||||||
fallbackDetailText(rawArgs, rawResult)
|
fallbackDetailText(rawArgs, rawResult)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"terminal", "execute_code" -> {
|
"terminal", "execute_code" -> {
|
||||||
val output = shellOutput(result)
|
val output = shellOutput(result)
|
||||||
if (output.isNotEmpty()) {
|
if (output.isNotEmpty()) {
|
||||||
@@ -1381,6 +1473,7 @@ object ToolViewBuilder {
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"web_extract" -> {
|
"web_extract" -> {
|
||||||
val direct = firstString(result, listOf("content", "text", "markdown", "body", "summary", "message"))
|
val direct = firstString(result, listOf("content", "text", "markdown", "body", "summary", "message"))
|
||||||
if (direct.isNotEmpty()) {
|
if (direct.isNotEmpty()) {
|
||||||
@@ -1393,20 +1486,24 @@ object ToolViewBuilder {
|
|||||||
.mapNotNull { item ->
|
.mapNotNull { item ->
|
||||||
val row = parseMaybeObject(item) ?: return@mapNotNull null
|
val row = parseMaybeObject(item) ?: return@mapNotNull null
|
||||||
firstString(row, listOf("content", "text", "markdown", "body"))
|
firstString(row, listOf("content", "text", "markdown", "body"))
|
||||||
}
|
}.filter { it.isNotEmpty() }
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.joinToString("\n\n---\n\n")
|
.joinToString("\n\n---\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fallbackDetailText(rawArgs, rawResult)
|
fallbackDetailText(rawArgs, rawResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
"read_file" -> {
|
"read_file" -> {
|
||||||
if (rawResult == null) {
|
if (rawResult == null) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
firstString(result, listOf("content", "text", "data", "body"))
|
firstString(result, listOf("content", "text", "data", "body"))
|
||||||
}
|
}
|
||||||
"memory" -> firstString(result, listOf("message", "error"))
|
|
||||||
|
"memory" -> {
|
||||||
|
firstString(result, listOf("message", "error"))
|
||||||
|
}
|
||||||
|
|
||||||
"edit_file", "patch", "write_file" -> {
|
"edit_file", "patch", "write_file" -> {
|
||||||
if (inlineDiffFromResult(rawResult).isNotEmpty()) {
|
if (inlineDiffFromResult(rawResult).isNotEmpty()) {
|
||||||
""
|
""
|
||||||
@@ -1416,11 +1513,27 @@ object ToolViewBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"web_search" -> fallbackDetailText(rawArgs, rawResult)
|
|
||||||
"cronjob" -> cronjobDetail(args, result)
|
"web_search" -> {
|
||||||
"todo" -> todoDetail(result) ?: ""
|
fallbackDetailText(rawArgs, rawResult)
|
||||||
"fact_store" -> factStoreDetail(result) ?: ""
|
}
|
||||||
"session_search" -> sessionSearchDetail(result) ?: ""
|
|
||||||
|
"cronjob" -> {
|
||||||
|
cronjobDetail(args, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
"todo" -> {
|
||||||
|
todoDetail(result) ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
"fact_store" -> {
|
||||||
|
factStoreDetail(result) ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
"session_search" -> {
|
||||||
|
sessionSearchDetail(result) ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
"skills_list" -> {
|
"skills_list" -> {
|
||||||
val skills =
|
val skills =
|
||||||
(result?.get("skills") as? JsonArray)
|
(result?.get("skills") as? JsonArray)
|
||||||
@@ -1435,10 +1548,10 @@ object ToolViewBuilder {
|
|||||||
if (desc.isNotEmpty()) lines += " $desc"
|
if (desc.isNotEmpty()) lines += " $desc"
|
||||||
if (cat.isNotEmpty()) lines += " [$cat]"
|
if (cat.isNotEmpty()) lines += " [$cat]"
|
||||||
lines.joinToString("\n")
|
lines.joinToString("\n")
|
||||||
}
|
}?.joinToString("\n\n")
|
||||||
?.joinToString("\n\n")
|
|
||||||
?: "No skills found"
|
?: "No skills found"
|
||||||
}
|
}
|
||||||
|
|
||||||
"skill_view" -> {
|
"skill_view" -> {
|
||||||
val content = firstString(result, listOf("content"))
|
val content = firstString(result, listOf("content"))
|
||||||
val linkedFiles = result?.get("linked_files") as? JsonObject
|
val linkedFiles = result?.get("linked_files") as? JsonObject
|
||||||
@@ -1456,12 +1569,19 @@ object ToolViewBuilder {
|
|||||||
?.joinToString("\n") { (k, v) ->
|
?.joinToString("\n") { (k, v) ->
|
||||||
val paths =
|
val paths =
|
||||||
when (v) {
|
when (v) {
|
||||||
is JsonArray ->
|
is JsonArray -> {
|
||||||
v.joinToString(
|
v.joinToString(
|
||||||
", ",
|
", ",
|
||||||
) { (it as? JsonPrimitive)?.content ?: it.toString() }
|
) { (it as? JsonPrimitive)?.content ?: it.toString() }
|
||||||
is JsonPrimitive -> v.content
|
}
|
||||||
else -> v.toString()
|
|
||||||
|
is JsonPrimitive -> {
|
||||||
|
v.content
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
v.toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
" 📎 $k: $paths"
|
" 📎 $k: $paths"
|
||||||
}
|
}
|
||||||
@@ -1475,6 +1595,7 @@ object ToolViewBuilder {
|
|||||||
if (content.isEmpty() && linkedFiles == null) append("No content available")
|
if (content.isEmpty() && linkedFiles == null) append("No content available")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"skill_manage" -> {
|
"skill_manage" -> {
|
||||||
val error = firstString(result, listOf("error"))
|
val error = firstString(result, listOf("error"))
|
||||||
if (error.isNotEmpty()) {
|
if (error.isNotEmpty()) {
|
||||||
@@ -1494,8 +1615,15 @@ object ToolViewBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"process" -> processDetail(args, result)
|
|
||||||
"x_search" -> xSearchDetail(result)
|
"process" -> {
|
||||||
|
processDetail(args, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
"x_search" -> {
|
||||||
|
xSearchDetail(result)
|
||||||
|
}
|
||||||
|
|
||||||
"vision_analyze" -> {
|
"vision_analyze" -> {
|
||||||
val error = firstString(result, listOf("error"))
|
val error = firstString(result, listOf("error"))
|
||||||
val description = firstString(result, listOf("description"))
|
val description = firstString(result, listOf("description"))
|
||||||
@@ -1507,6 +1635,7 @@ object ToolViewBuilder {
|
|||||||
else -> "No description available"
|
else -> "No description available"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"tool_search" -> {
|
"tool_search" -> {
|
||||||
val matches = result?.get("matches") as? JsonArray
|
val matches = result?.get("matches") as? JsonArray
|
||||||
matches
|
matches
|
||||||
@@ -1517,10 +1646,10 @@ object ToolViewBuilder {
|
|||||||
val lines = mutableListOf("🔧 $name")
|
val lines = mutableListOf("🔧 $name")
|
||||||
if (desc.isNotEmpty()) lines += " $desc"
|
if (desc.isNotEmpty()) lines += " $desc"
|
||||||
lines.joinToString("\n")
|
lines.joinToString("\n")
|
||||||
}
|
}?.joinToString("\n\n")
|
||||||
?.joinToString("\n\n")
|
|
||||||
?: "No matching tools"
|
?: "No matching tools"
|
||||||
}
|
}
|
||||||
|
|
||||||
"image_generate" -> {
|
"image_generate" -> {
|
||||||
val error = firstString(result, listOf("error"))
|
val error = firstString(result, listOf("error"))
|
||||||
val imageUrl = firstString(result, listOf("image"))
|
val imageUrl = firstString(result, listOf("image"))
|
||||||
@@ -1539,6 +1668,7 @@ object ToolViewBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"project_list" -> {
|
"project_list" -> {
|
||||||
val projects = result?.get("projects") as? JsonArray
|
val projects = result?.get("projects") as? JsonArray
|
||||||
projects
|
projects
|
||||||
@@ -1555,10 +1685,10 @@ object ToolViewBuilder {
|
|||||||
if (path.isNotEmpty()) lines += " 📍 $path"
|
if (path.isNotEmpty()) lines += " 📍 $path"
|
||||||
if (isActive) lines += " ✅ ACTIVE PROJECT"
|
if (isActive) lines += " ✅ ACTIVE PROJECT"
|
||||||
lines.joinToString("\n")
|
lines.joinToString("\n")
|
||||||
}
|
}?.joinToString("\n\n")
|
||||||
?.joinToString("\n\n")
|
|
||||||
?: ""
|
?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
"project_create", "project_switch" -> {
|
"project_create", "project_switch" -> {
|
||||||
val error = firstString(result, listOf("error"))
|
val error = firstString(result, listOf("error"))
|
||||||
val actionLabel = if (toolName == "project_create") "Created" else "Switched to"
|
val actionLabel = if (toolName == "project_create") "Created" else "Switched to"
|
||||||
@@ -1577,9 +1707,18 @@ object ToolViewBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"read_terminal" -> readTerminalDetail(result)
|
|
||||||
"computer_use" -> computerUseDetail(args, result)
|
"read_terminal" -> {
|
||||||
else -> fallbackDetailText(rawArgs, rawResult)
|
readTerminalDetail(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
"computer_use" -> {
|
||||||
|
computerUseDetail(args, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
fallbackDetailText(rawArgs, rawResult)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1601,8 +1740,7 @@ object ToolViewBuilder {
|
|||||||
val name = firstString(row, listOf("name", "id")).ifEmpty { "job" }
|
val name = firstString(row, listOf("name", "id")).ifEmpty { "job" }
|
||||||
val sched = firstString(row, listOf("schedule_display", "schedule"))
|
val sched = firstString(row, listOf("schedule_display", "schedule"))
|
||||||
if (sched.isNotEmpty()) "- $name · $sched" else "- $name"
|
if (sched.isNotEmpty()) "- $name · $sched" else "- $name"
|
||||||
}
|
}.joinToString("\n")
|
||||||
.joinToString("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val rows =
|
val rows =
|
||||||
@@ -1639,8 +1777,7 @@ object ToolViewBuilder {
|
|||||||
else -> "[ ]"
|
else -> "[ ]"
|
||||||
}
|
}
|
||||||
"$marker $id. $content"
|
"$marker $id. $content"
|
||||||
}
|
}.joinToString("\n")
|
||||||
.joinToString("\n")
|
|
||||||
.takeIf { it.isNotEmpty() }
|
.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1669,8 +1806,7 @@ object ToolViewBuilder {
|
|||||||
} else {
|
} else {
|
||||||
"#$fid $content"
|
"#$fid $content"
|
||||||
}
|
}
|
||||||
}
|
}.joinToString("\n")
|
||||||
.joinToString("\n")
|
|
||||||
.takeIf { it.isNotEmpty() }
|
.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1700,8 +1836,7 @@ object ToolViewBuilder {
|
|||||||
snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: ""
|
snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: ""
|
||||||
|
|
||||||
"━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine"
|
"━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine"
|
||||||
}
|
}?.joinToString("\n")
|
||||||
?.joinToString("\n")
|
|
||||||
?.takeIf { it.isNotEmpty() }
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
val anchorId = intValue(result?.get("around_message_id"))
|
val anchorId = intValue(result?.get("around_message_id"))
|
||||||
@@ -1727,8 +1862,7 @@ object ToolViewBuilder {
|
|||||||
val cleanContent = content.take(300).replace("\n", " ")
|
val cleanContent = content.take(300).replace("\n", " ")
|
||||||
|
|
||||||
"[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent"
|
"[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent"
|
||||||
}
|
}?.joinToString("\n")
|
||||||
?.joinToString("\n")
|
|
||||||
?.takeIf { it.isNotEmpty() }
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
return formattedResults ?: formattedMessages
|
return formattedResults ?: formattedMessages
|
||||||
@@ -1756,8 +1890,7 @@ object ToolViewBuilder {
|
|||||||
if (pStatus.isNotEmpty()) parts += " Status: $pStatus"
|
if (pStatus.isNotEmpty()) parts += " Status: $pStatus"
|
||||||
if (running != null) parts += " Running: $running"
|
if (running != null) parts += " Running: $running"
|
||||||
parts.joinToString("\n")
|
parts.joinToString("\n")
|
||||||
}
|
}.joinToString("\n\n")
|
||||||
.joinToString("\n\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val output = firstString(result, listOf("output"))
|
val output = firstString(result, listOf("output"))
|
||||||
@@ -1782,7 +1915,10 @@ object ToolViewBuilder {
|
|||||||
val degraded = (result?.get("degraded") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true
|
val degraded = (result?.get("degraded") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
error.isNotEmpty() -> "❌ $error"
|
error.isNotEmpty() -> {
|
||||||
|
"❌ $error"
|
||||||
|
}
|
||||||
|
|
||||||
answer.isNotEmpty() -> {
|
answer.isNotEmpty() -> {
|
||||||
val lines = mutableListOf(answer)
|
val lines = mutableListOf(answer)
|
||||||
if (citations != null && citations.isNotEmpty()) {
|
if (citations != null && citations.isNotEmpty()) {
|
||||||
@@ -1798,7 +1934,10 @@ object ToolViewBuilder {
|
|||||||
if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge"
|
if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge"
|
||||||
lines.joinToString("\n")
|
lines.joinToString("\n")
|
||||||
}
|
}
|
||||||
else -> "No results"
|
|
||||||
|
else -> {
|
||||||
|
"No results"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1845,8 +1984,7 @@ object ToolViewBuilder {
|
|||||||
.mapNotNull { el ->
|
.mapNotNull { el ->
|
||||||
val e = parseMaybeObject(el) ?: return@mapNotNull null
|
val e = parseMaybeObject(el) ?: return@mapNotNull null
|
||||||
if (firstString(e, listOf("type")) == "text") firstString(e, listOf("text")) else null
|
if (firstString(e, listOf("type")) == "text") firstString(e, listOf("text")) else null
|
||||||
}
|
}.joinToString("\n")
|
||||||
.joinToString("\n")
|
|
||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
@@ -1866,12 +2004,13 @@ object ToolViewBuilder {
|
|||||||
val body =
|
val body =
|
||||||
buildString {
|
buildString {
|
||||||
val textPart =
|
val textPart =
|
||||||
multimodalText.ifEmpty {
|
multimodalText
|
||||||
firstString(
|
.ifEmpty {
|
||||||
result,
|
firstString(
|
||||||
listOf("text_summary"),
|
result,
|
||||||
)
|
listOf("text_summary"),
|
||||||
}.ifEmpty { textSummary }
|
)
|
||||||
|
}.ifEmpty { textSummary }
|
||||||
if (textPart.isNotEmpty()) {
|
if (textPart.isNotEmpty()) {
|
||||||
append(textPart)
|
append(textPart)
|
||||||
if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) {
|
if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) {
|
||||||
|
|||||||
@@ -107,21 +107,25 @@ fun ActionProgressDialog(
|
|||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
when (state.phase) {
|
when (state.phase) {
|
||||||
ActionProgressPhase.STARTING ->
|
ActionProgressPhase.STARTING -> {
|
||||||
stringResource(R.string.action_progress_starting)
|
stringResource(R.string.action_progress_starting)
|
||||||
|
}
|
||||||
|
|
||||||
ActionProgressPhase.RUNNING ->
|
ActionProgressPhase.RUNNING -> {
|
||||||
stringResource(R.string.action_progress_running)
|
stringResource(R.string.action_progress_running)
|
||||||
|
}
|
||||||
|
|
||||||
ActionProgressPhase.SUCCEEDED ->
|
ActionProgressPhase.SUCCEEDED -> {
|
||||||
stringResource(R.string.action_progress_succeeded)
|
stringResource(R.string.action_progress_succeeded)
|
||||||
|
}
|
||||||
|
|
||||||
ActionProgressPhase.FAILED ->
|
ActionProgressPhase.FAILED -> {
|
||||||
if (exitCode != null) {
|
if (exitCode != null) {
|
||||||
stringResource(R.string.action_progress_failed_exit, exitCode)
|
stringResource(R.string.action_progress_failed_exit, exitCode)
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.action_progress_failed)
|
stringResource(R.string.action_progress_failed)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ fun BotAvatar(
|
|||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model =
|
model =
|
||||||
ImageRequest.Builder(LocalContext.current)
|
ImageRequest
|
||||||
|
.Builder(LocalContext.current)
|
||||||
.data(imageUrl)
|
.data(imageUrl)
|
||||||
.crossfade(true)
|
.crossfade(true)
|
||||||
.build(),
|
.build(),
|
||||||
@@ -137,21 +138,20 @@ fun BotAvatar(
|
|||||||
fun resolveAvatarShape(
|
fun resolveAvatarShape(
|
||||||
shapeKey: String?,
|
shapeKey: String?,
|
||||||
size: Dp,
|
size: Dp,
|
||||||
): Shape {
|
): Shape =
|
||||||
return when (shapeKey?.lowercase()?.trim()) {
|
when (shapeKey?.lowercase()?.trim()) {
|
||||||
"square", "box", "boxy" -> RoundedCornerShape(size * 0.15f)
|
"square", "box", "boxy" -> RoundedCornerShape(size * 0.15f)
|
||||||
"rounded", "nub", "organic" -> RoundedCornerShape(size * 0.32f)
|
"rounded", "nub", "organic" -> RoundedCornerShape(size * 0.32f)
|
||||||
"hexagon", "cut", "diamond", "cloud", "sun" -> CutCornerShape(size * 0.25f)
|
"hexagon", "cut", "diamond", "cloud", "sun" -> CutCornerShape(size * 0.25f)
|
||||||
"circle", "round" -> CircleShape
|
"circle", "round" -> CircleShape
|
||||||
else -> CircleShape
|
else -> CircleShape
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps known icon keys to Material [ImageVector] icons.
|
* Maps known icon keys to Material [ImageVector] icons.
|
||||||
*/
|
*/
|
||||||
fun resolveAvatarIcon(iconKey: String?): ImageVector? {
|
fun resolveAvatarIcon(iconKey: String?): ImageVector? =
|
||||||
return when (iconKey?.lowercase()?.trim()) {
|
when (iconKey?.lowercase()?.trim()) {
|
||||||
"code", "dev", "terminal" -> Icons.Filled.Code
|
"code", "dev", "terminal" -> Icons.Filled.Code
|
||||||
"build", "tool", "tools" -> Icons.Filled.Build
|
"build", "tool", "tools" -> Icons.Filled.Build
|
||||||
"psychology", "brain", "ai", "research", "intel" -> Icons.Filled.Psychology
|
"psychology", "brain", "ai", "research", "intel" -> Icons.Filled.Psychology
|
||||||
@@ -162,7 +162,6 @@ fun resolveAvatarIcon(iconKey: String?): ImageVector? {
|
|||||||
"robot", "bot", "smart_toy" -> Icons.Filled.SmartToy
|
"robot", "bot", "smart_toy" -> Icons.Filled.SmartToy
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract 1-2 uppercase letters from name for initials fallback.
|
* Extract 1-2 uppercase letters from name for initials fallback.
|
||||||
|
|||||||
@@ -16,41 +16,42 @@ import com.m57.hermescontrol.theme.IconDefaultFill
|
|||||||
* original path data (viewBox 0 -960 960 960, translated into compose space).
|
* original path data (viewBox 0 -960 960 960, translated into compose space).
|
||||||
*/
|
*/
|
||||||
val NeurologyIcon: ImageVector by lazy {
|
val NeurologyIcon: ImageVector by lazy {
|
||||||
ImageVector.Builder(
|
ImageVector
|
||||||
name = "Neurology",
|
.Builder(
|
||||||
defaultWidth = 24.dp,
|
name = "Neurology",
|
||||||
defaultHeight = 24.dp,
|
defaultWidth = 24.dp,
|
||||||
viewportWidth = 960f,
|
defaultHeight = 24.dp,
|
||||||
viewportHeight = 960f,
|
viewportWidth = 960f,
|
||||||
).apply {
|
viewportHeight = 960f,
|
||||||
addGroup(
|
).apply {
|
||||||
name = "translate",
|
addGroup(
|
||||||
rotate = 0f,
|
name = "translate",
|
||||||
pivotX = 0f,
|
rotate = 0f,
|
||||||
pivotY = 0f,
|
pivotX = 0f,
|
||||||
scaleX = 1f,
|
pivotY = 0f,
|
||||||
scaleY = 1f,
|
scaleX = 1f,
|
||||||
translationX = 0f,
|
scaleY = 1f,
|
||||||
translationY = 960f,
|
translationX = 0f,
|
||||||
clipPathData = emptyList(),
|
translationY = 960f,
|
||||||
)
|
clipPathData = emptyList(),
|
||||||
addPath(
|
)
|
||||||
pathData = addPathNodes(PATH),
|
addPath(
|
||||||
pathFillType = PathFillType.NonZero,
|
pathData = addPathNodes(PATH),
|
||||||
name = "NeurologyPath",
|
pathFillType = PathFillType.NonZero,
|
||||||
fill = SolidColor(IconDefaultFill),
|
name = "NeurologyPath",
|
||||||
fillAlpha = 1f,
|
fill = SolidColor(IconDefaultFill),
|
||||||
stroke = null,
|
fillAlpha = 1f,
|
||||||
strokeAlpha = 1f,
|
stroke = null,
|
||||||
strokeLineWidth = 1f,
|
strokeAlpha = 1f,
|
||||||
strokeLineCap = StrokeCap.Butt,
|
strokeLineWidth = 1f,
|
||||||
strokeLineJoin = StrokeJoin.Miter,
|
strokeLineCap = StrokeCap.Butt,
|
||||||
strokeLineMiter = 4f,
|
strokeLineJoin = StrokeJoin.Miter,
|
||||||
trimPathStart = 0f,
|
strokeLineMiter = 4f,
|
||||||
trimPathEnd = 1f,
|
trimPathStart = 0f,
|
||||||
trimPathOffset = 0f,
|
trimPathEnd = 1f,
|
||||||
)
|
trimPathOffset = 0f,
|
||||||
}.build()
|
)
|
||||||
|
}.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val PATH =
|
private const val PATH =
|
||||||
|
|||||||
@@ -82,22 +82,27 @@ fun PressureBanner(
|
|||||||
|
|
||||||
val message =
|
val message =
|
||||||
when (trigger) {
|
when (trigger) {
|
||||||
PressureTrigger.DISK_CRITICAL ->
|
PressureTrigger.DISK_CRITICAL -> {
|
||||||
stringResource(R.string.pressure_banner_disk_critical) +
|
stringResource(R.string.pressure_banner_disk_critical) +
|
||||||
diskFreeSuffix(disk)
|
diskFreeSuffix(disk)
|
||||||
|
}
|
||||||
|
|
||||||
PressureTrigger.DISK_ELEVATED ->
|
PressureTrigger.DISK_ELEVATED -> {
|
||||||
stringResource(R.string.pressure_banner_disk_elevated) +
|
stringResource(R.string.pressure_banner_disk_elevated) +
|
||||||
diskFreeSuffix(disk)
|
diskFreeSuffix(disk)
|
||||||
|
}
|
||||||
|
|
||||||
PressureTrigger.MEMORY_CRITICAL ->
|
PressureTrigger.MEMORY_CRITICAL -> {
|
||||||
stringResource(R.string.pressure_banner_memory_critical)
|
stringResource(R.string.pressure_banner_memory_critical)
|
||||||
|
}
|
||||||
|
|
||||||
PressureTrigger.OOM_RESTART ->
|
PressureTrigger.OOM_RESTART -> {
|
||||||
stringResource(R.string.pressure_banner_memory_oom)
|
stringResource(R.string.pressure_banner_memory_oom)
|
||||||
|
}
|
||||||
|
|
||||||
PressureTrigger.MEMORY_ELEVATED ->
|
PressureTrigger.MEMORY_ELEVATED -> {
|
||||||
stringResource(R.string.pressure_banner_memory_elevated)
|
stringResource(R.string.pressure_banner_memory_elevated)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ data class ConfigRow(
|
|||||||
) {
|
) {
|
||||||
/** Human label: last path segment, underscores → spaces. */
|
/** Human label: last path segment, underscores → spaces. */
|
||||||
val label: String =
|
val label: String =
|
||||||
key.split(".").last().replace("_", " ").replaceFirstChar { it.uppercase() }
|
key
|
||||||
|
.split(".")
|
||||||
|
.last()
|
||||||
|
.replace("_", " ")
|
||||||
|
.replaceFirstChar { it.uppercase() }
|
||||||
|
|
||||||
/** Search/display text of the current value. */
|
/** Search/display text of the current value. */
|
||||||
val valueText: String = value?.let(::jsonText) ?: ""
|
val valueText: String = value?.let(::jsonText) ?: ""
|
||||||
|
|||||||
@@ -578,7 +578,10 @@ private fun ConfigFieldCard(
|
|||||||
val field = row.field
|
val field = row.field
|
||||||
val description =
|
val description =
|
||||||
field?.description
|
field?.description
|
||||||
?: row.key.replace(".", " → ").replace("_", " ").replaceFirstChar { it.uppercase() }
|
?: row.key
|
||||||
|
.replace(".", " → ")
|
||||||
|
.replace("_", " ")
|
||||||
|
.replaceFirstChar { it.uppercase() }
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier =
|
modifier =
|
||||||
|
|||||||
@@ -552,19 +552,32 @@ class CronJobsViewModel :
|
|||||||
): CronJobEditorState =
|
): CronJobEditorState =
|
||||||
when (name) {
|
when (name) {
|
||||||
"name" -> copy(name = value)
|
"name" -> copy(name = value)
|
||||||
|
|
||||||
"schedule" -> copy(schedule = value)
|
"schedule" -> copy(schedule = value)
|
||||||
|
|
||||||
"prompt" -> copy(prompt = value)
|
"prompt" -> copy(prompt = value)
|
||||||
|
|
||||||
"deliver" -> copy(deliver = value)
|
"deliver" -> copy(deliver = value)
|
||||||
|
|
||||||
"skills" -> copy(skills = value)
|
"skills" -> copy(skills = value)
|
||||||
|
|
||||||
"model" -> copy(model = value)
|
"model" -> copy(model = value)
|
||||||
|
|
||||||
"provider" -> copy(provider = value)
|
"provider" -> copy(provider = value)
|
||||||
|
|
||||||
"base_url" -> copy(base_url = value)
|
"base_url" -> copy(base_url = value)
|
||||||
|
|
||||||
"script" -> copy(script = value)
|
"script" -> copy(script = value)
|
||||||
|
|
||||||
"workdir" -> copy(workdir = value)
|
"workdir" -> copy(workdir = value)
|
||||||
|
|
||||||
"run_continuity" -> copy(runContinuity = value.toBooleanStrictOrNull() ?: false)
|
"run_continuity" -> copy(runContinuity = value.toBooleanStrictOrNull() ?: false)
|
||||||
|
|
||||||
// Monitor fields are mutually exclusive: entering one clears the other.
|
// Monitor fields are mutually exclusive: entering one clears the other.
|
||||||
"monitor_script" -> copy(monitor_script = value, monitor_url = if (value.isNotBlank()) "" else monitor_url)
|
"monitor_script" -> copy(monitor_script = value, monitor_url = if (value.isNotBlank()) "" else monitor_url)
|
||||||
|
|
||||||
"monitor_url" -> copy(monitor_url = value, monitor_script = if (value.isNotBlank()) "" else monitor_script)
|
"monitor_url" -> copy(monitor_url = value, monitor_script = if (value.isNotBlank()) "" else monitor_script)
|
||||||
|
|
||||||
else -> this
|
else -> this
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,8 +631,8 @@ class CronJobsViewModel :
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Monitor fields configured in the editor (at most one is non-blank). */
|
/** Monitor fields configured in the editor (at most one is non-blank). */
|
||||||
private fun editorMonitorUpdates(editor: CronJobEditorState): Map<String, String> {
|
private fun editorMonitorUpdates(editor: CronJobEditorState): Map<String, String> =
|
||||||
return buildMap {
|
buildMap {
|
||||||
if (editor.monitor_script.isNotBlank()) {
|
if (editor.monitor_script.isNotBlank()) {
|
||||||
put("monitor_script", editor.monitor_script.trim())
|
put("monitor_script", editor.monitor_script.trim())
|
||||||
}
|
}
|
||||||
@@ -627,5 +640,4 @@ class CronJobsViewModel :
|
|||||||
put("monitor_url", editor.monitor_url.trim())
|
put("monitor_url", editor.monitor_url.trim())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -55,17 +55,28 @@ enum class KanbanTaskAction(
|
|||||||
/** Desktop parity: which actions are valid from a given status. */
|
/** Desktop parity: which actions are valid from a given status. */
|
||||||
fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
|
fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
|
||||||
when (status) {
|
when (status) {
|
||||||
"triage" -> listOf(KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
"triage" -> {
|
||||||
"todo" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
listOf(KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
||||||
"scheduled" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
}
|
||||||
"ready" ->
|
|
||||||
|
"todo" -> {
|
||||||
|
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
"scheduled" -> {
|
||||||
|
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
"ready" -> {
|
||||||
listOf(
|
listOf(
|
||||||
KanbanTaskAction.TRIAGE,
|
KanbanTaskAction.TRIAGE,
|
||||||
KanbanTaskAction.BLOCK,
|
KanbanTaskAction.BLOCK,
|
||||||
KanbanTaskAction.COMPLETE,
|
KanbanTaskAction.COMPLETE,
|
||||||
KanbanTaskAction.ARCHIVE,
|
KanbanTaskAction.ARCHIVE,
|
||||||
)
|
)
|
||||||
"running" ->
|
}
|
||||||
|
|
||||||
|
"running" -> {
|
||||||
listOf(
|
listOf(
|
||||||
KanbanTaskAction.TRIAGE,
|
KanbanTaskAction.TRIAGE,
|
||||||
KanbanTaskAction.READY,
|
KanbanTaskAction.READY,
|
||||||
@@ -73,22 +84,38 @@ fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
|
|||||||
KanbanTaskAction.COMPLETE,
|
KanbanTaskAction.COMPLETE,
|
||||||
KanbanTaskAction.ARCHIVE,
|
KanbanTaskAction.ARCHIVE,
|
||||||
)
|
)
|
||||||
"blocked" ->
|
}
|
||||||
|
|
||||||
|
"blocked" -> {
|
||||||
listOf(
|
listOf(
|
||||||
KanbanTaskAction.TRIAGE,
|
KanbanTaskAction.TRIAGE,
|
||||||
KanbanTaskAction.UNBLOCK,
|
KanbanTaskAction.UNBLOCK,
|
||||||
KanbanTaskAction.COMPLETE,
|
KanbanTaskAction.COMPLETE,
|
||||||
KanbanTaskAction.ARCHIVE,
|
KanbanTaskAction.ARCHIVE,
|
||||||
)
|
)
|
||||||
"review" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
}
|
||||||
"done" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
|
||||||
"archived" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY)
|
"review" -> {
|
||||||
else -> emptyList()
|
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
"done" -> {
|
||||||
|
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
"archived" -> {
|
||||||
|
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class KanbanViewModel(
|
class KanbanViewModel(
|
||||||
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
|
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
|
||||||
) : ViewModel(), ToastHost {
|
) : ViewModel(),
|
||||||
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(KanbanUiState())
|
private val _uiState = MutableStateFlow(KanbanUiState())
|
||||||
val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
|||||||
@@ -456,6 +456,7 @@ class McpServersViewModel :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to start OAuth: ${result.error.message}") }
|
_uiState.update { it.copy(toastMessage = "Failed to start OAuth: ${result.error.message}") }
|
||||||
}
|
}
|
||||||
@@ -489,6 +490,7 @@ class McpServersViewModel :
|
|||||||
}
|
}
|
||||||
loadServers()
|
loadServers()
|
||||||
}
|
}
|
||||||
|
|
||||||
"error" -> {
|
"error" -> {
|
||||||
polling = false
|
polling = false
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@@ -498,15 +500,18 @@ class McpServersViewModel :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"authorization_required" -> {
|
"authorization_required" -> {
|
||||||
// Keep polling
|
// Keep polling
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
// Check if worker is done or expired
|
// Check if worker is done or expired
|
||||||
// Continue polling until status transitions
|
// Continue polling until status transitions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
// Keep polling or stop after too many failures? Let's just log/toast n retry a few times
|
// Keep polling or stop after too many failures? Let's just log/toast n retry a few times
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ data class MemoryUiState(
|
|||||||
* Retrofit suspend calls already run off the caller thread, keeping tests
|
* Retrofit suspend calls already run off the caller thread, keeping tests
|
||||||
* free of the static Dispatchers mock that bleeds across test classes.
|
* free of the static Dispatchers mock that bleeds across test classes.
|
||||||
*/
|
*/
|
||||||
class MemoryViewModel : ViewModel(), ToastHost {
|
class MemoryViewModel :
|
||||||
|
ViewModel(),
|
||||||
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(MemoryUiState())
|
private val _uiState = MutableStateFlow(MemoryUiState())
|
||||||
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
@@ -41,15 +43,18 @@ class MemoryViewModel : ViewModel(), ToastHost {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = safeApiCall { ApiClient.hermesApi.getMemory() }
|
val result = safeApiCall { ApiClient.hermesApi.getMemory() }
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success ->
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(isLoading = false, memory = result.data) }
|
_uiState.update { it.copy(isLoading = false, memory = result.data) }
|
||||||
is NetworkResult.Failure ->
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = result.error.message,
|
errorMessage = result.error.message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loadLearningGraph()
|
loadLearningGraph()
|
||||||
}
|
}
|
||||||
@@ -70,20 +75,23 @@ class MemoryViewModel : ViewModel(), ToastHost {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = safeApiCall { ApiClient.hermesApi.resetMemory(MemoryResetRequest(target = target)) }
|
val result = safeApiCall { ApiClient.hermesApi.resetMemory(MemoryResetRequest(target = target)) }
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success ->
|
is NetworkResult.Success -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
resetting = null,
|
resetting = null,
|
||||||
toastMessage = "Memory ($target) reset successfully",
|
toastMessage = "Memory ($target) reset successfully",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is NetworkResult.Failure ->
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
resetting = null,
|
resetting = null,
|
||||||
toastMessage = "Failed to reset memory: ${result.error.message}",
|
toastMessage = "Failed to reset memory: ${result.error.message}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,10 +328,9 @@ private fun ApprovedCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun formatAge(ageMinutes: Int): String {
|
private fun formatAge(ageMinutes: Int): String =
|
||||||
return if (ageMinutes < 60) {
|
if (ageMinutes < 60) {
|
||||||
stringResource(R.string.pairing_label_request_age_m, ageMinutes)
|
stringResource(R.string.pairing_label_request_age_m, ageMinutes)
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.pairing_label_request_age_hm, ageMinutes / 60, ageMinutes % 60)
|
stringResource(R.string.pairing_label_request_age_hm, ageMinutes / 60, ageMinutes % 60)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@@ -334,15 +334,16 @@ private fun ProviderFieldRow(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
when (field.kind) {
|
when (field.kind) {
|
||||||
"select" ->
|
"select" -> {
|
||||||
ExposedDropdownField(
|
ExposedDropdownField(
|
||||||
label = field.label.ifEmpty { field.key },
|
label = field.label.ifEmpty { field.key },
|
||||||
options = field.options.map { it.value }.ifEmpty { listOf(value) },
|
options = field.options.map { it.value }.ifEmpty { listOf(value) },
|
||||||
selectedValue = value,
|
selectedValue = value,
|
||||||
onOptionSelected = onChange,
|
onOptionSelected = onChange,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
"bool" ->
|
"bool" -> {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -365,8 +366,9 @@ private fun ProviderFieldRow(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else ->
|
else -> {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onChange,
|
onValueChange = onChange,
|
||||||
@@ -400,6 +402,7 @@ private fun ProviderFieldRow(
|
|||||||
KeyboardOptions.Default
|
KeyboardOptions.Default
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-9
@@ -47,7 +47,9 @@ data class MemoryProviderDetailUiState(
|
|||||||
* [setProvider] + [load] per entry instead (same bug/fix as #782's
|
* [setProvider] + [load] per entry instead (same bug/fix as #782's
|
||||||
* ToolsetDetailViewModel).
|
* ToolsetDetailViewModel).
|
||||||
*/
|
*/
|
||||||
class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
|
class MemoryProviderDetailViewModel :
|
||||||
|
ViewModel(),
|
||||||
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(MemoryProviderDetailUiState())
|
private val _uiState = MutableStateFlow(MemoryProviderDetailUiState())
|
||||||
val uiState: StateFlow<MemoryProviderDetailUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<MemoryProviderDetailUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
@@ -74,13 +76,17 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
|
|||||||
val statusResult = safeApiCall { ApiClient.hermesApi.getMemory() }
|
val statusResult = safeApiCall { ApiClient.hermesApi.getMemory() }
|
||||||
val status =
|
val status =
|
||||||
when (statusResult) {
|
when (statusResult) {
|
||||||
is NetworkResult.Success ->
|
is NetworkResult.Success -> {
|
||||||
statusResult.data.providers.firstOrNull { it.name == name }
|
statusResult.data.providers.firstOrNull { it.name == name }
|
||||||
else -> null
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val configResult = safeApiCall { ApiClient.hermesApi.getMemoryProviderConfig(name) }
|
val configResult = safeApiCall { ApiClient.hermesApi.getMemoryProviderConfig(name) }
|
||||||
when (configResult) {
|
when (configResult) {
|
||||||
is NetworkResult.Success ->
|
is NetworkResult.Success -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -90,7 +96,9 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
|
|||||||
edits = seedEdits(configResult.data),
|
edits = seedEdits(configResult.data),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is NetworkResult.Failure ->
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -99,6 +107,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
|
|||||||
errorMessage = configResult.error.message,
|
errorMessage = configResult.error.message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +152,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success ->
|
is NetworkResult.Success -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
saving = false,
|
saving = false,
|
||||||
@@ -154,13 +163,16 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
|
|||||||
toastMessage = "Memory provider settings saved",
|
toastMessage = "Memory provider settings saved",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is NetworkResult.Failure ->
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
saving = false,
|
saving = false,
|
||||||
toastMessage = "Failed to save settings: ${result.error.message}",
|
toastMessage = "Failed to save settings: ${result.error.message}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
refreshStatus(name)
|
refreshStatus(name)
|
||||||
}
|
}
|
||||||
@@ -180,7 +192,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
|
|||||||
ApiClient.hermesApi.setupMemoryProvider(name, MemoryProviderSetupRequest())
|
ApiClient.hermesApi.setupMemoryProvider(name, MemoryProviderSetupRequest())
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success ->
|
is NetworkResult.Success -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
runningSetup = false,
|
runningSetup = false,
|
||||||
@@ -193,13 +205,16 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is NetworkResult.Failure ->
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
runningSetup = false,
|
runningSetup = false,
|
||||||
toastMessage = "Setup failed: ${result.error.message}",
|
toastMessage = "Setup failed: ${result.error.message}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
refreshStatus(name)
|
refreshStatus(name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ internal fun ChatSection(
|
|||||||
) {
|
) {
|
||||||
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
|
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
|
||||||
val currentIndex =
|
val currentIndex =
|
||||||
fontScaleOptions.indexOfFirst { abs(it - chatFontScale) < 0.05f }
|
fontScaleOptions
|
||||||
|
.indexOfFirst { abs(it - chatFontScale) < 0.05f }
|
||||||
.takeIf { it >= 0 } ?: 1
|
.takeIf { it >= 0 } ?: 1
|
||||||
|
|
||||||
SectionCard {
|
SectionCard {
|
||||||
|
|||||||
@@ -884,7 +884,9 @@ private fun SkillScanSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
isScanning -> Unit
|
isScanning -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
scanError != null -> {
|
scanError != null -> {
|
||||||
Surface(
|
Surface(
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ data class SystemUiState(
|
|||||||
val updateConfirmOpen: Boolean = false,
|
val updateConfirmOpen: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
class SystemViewModel(application: Application) :
|
class SystemViewModel(
|
||||||
AndroidViewModel(application),
|
application: Application,
|
||||||
|
) : AndroidViewModel(application),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "SystemViewModel"
|
private const val TAG = "SystemViewModel"
|
||||||
|
|||||||
@@ -680,11 +680,23 @@ private fun PostSetupRunner(
|
|||||||
|
|
||||||
private fun ToolsetProvider.statusTextRes(): Int =
|
private fun ToolsetProvider.statusTextRes(): Int =
|
||||||
when (status) {
|
when (status) {
|
||||||
"ready" -> R.string.toolset_status_ready
|
"ready" -> {
|
||||||
"needs_keys" -> R.string.toolset_status_needs_keys
|
R.string.toolset_status_ready
|
||||||
"needs_auth" -> R.string.toolset_status_needs_auth
|
}
|
||||||
"needs_setup" -> R.string.toolset_status_needs_setup
|
|
||||||
else ->
|
"needs_keys" -> {
|
||||||
|
R.string.toolset_status_needs_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
"needs_auth" -> {
|
||||||
|
R.string.toolset_status_needs_auth
|
||||||
|
}
|
||||||
|
|
||||||
|
"needs_setup" -> {
|
||||||
|
R.string.toolset_status_needs_setup
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
// Older backends may omit `status` — fall back to the legacy
|
// Older backends may omit `status` — fall back to the legacy
|
||||||
// env-var heuristic the desktop used before server-computed
|
// env-var heuristic the desktop used before server-computed
|
||||||
// readiness landed.
|
// readiness landed.
|
||||||
@@ -693,16 +705,24 @@ private fun ToolsetProvider.statusTextRes(): Int =
|
|||||||
} else {
|
} else {
|
||||||
R.string.toolset_status_needs_keys
|
R.string.toolset_status_needs_keys
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ToolsetProvider.statusType(): StatusBadgeType =
|
private fun ToolsetProvider.statusType(): StatusBadgeType =
|
||||||
when (status) {
|
when (status) {
|
||||||
"ready" -> StatusBadgeType.SUCCESS
|
"ready" -> {
|
||||||
"needs_keys", "needs_auth", "needs_setup" -> StatusBadgeType.WARNING
|
StatusBadgeType.SUCCESS
|
||||||
else ->
|
}
|
||||||
|
|
||||||
|
"needs_keys", "needs_auth", "needs_setup" -> {
|
||||||
|
StatusBadgeType.WARNING
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
if (envVars.isEmpty() || envVars.all { it.isSet }) {
|
if (envVars.isEmpty() || envVars.all { it.isSet }) {
|
||||||
StatusBadgeType.SUCCESS
|
StatusBadgeType.SUCCESS
|
||||||
} else {
|
} else {
|
||||||
StatusBadgeType.WARNING
|
StatusBadgeType.WARNING
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,9 @@ data class ToolsetDetailUiState(
|
|||||||
* first-opened toolset into every entry — bug found on-device 2026-08-06).
|
* first-opened toolset into every entry — bug found on-device 2026-08-06).
|
||||||
* The screen drives [setToolset] + [loadConfig] per entry instead.
|
* The screen drives [setToolset] + [loadConfig] per entry instead.
|
||||||
*/
|
*/
|
||||||
class ToolsetDetailViewModel : ViewModel(), ToastHost {
|
class ToolsetDetailViewModel :
|
||||||
|
ViewModel(),
|
||||||
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(ToolsetDetailUiState())
|
private val _uiState = MutableStateFlow(ToolsetDetailUiState())
|
||||||
val uiState: StateFlow<ToolsetDetailUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ToolsetDetailUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
@@ -101,11 +103,12 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure ->
|
is NetworkResult.Failure -> {
|
||||||
state.copy(
|
state.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Failed to load toolset config: ${result.error.message}",
|
errorMessage = "Failed to load toolset config: ${result.error.message}",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,11 +153,12 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure ->
|
is NetworkResult.Failure -> {
|
||||||
state.copy(
|
state.copy(
|
||||||
selectingProvider = null,
|
selectingProvider = null,
|
||||||
toastMessage = "Failed to select backend: ${result.error.message}",
|
toastMessage = "Failed to select backend: ${result.error.message}",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,18 +180,20 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
|
|||||||
}
|
}
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success ->
|
is NetworkResult.Success -> {
|
||||||
state.copy(
|
state.copy(
|
||||||
savingEnvKey = null,
|
savingEnvKey = null,
|
||||||
config = state.config?.let { patchEnvIsSet(it, key, true) },
|
config = state.config?.let { patchEnvIsSet(it, key, true) },
|
||||||
toastMessage = "Saved $key",
|
toastMessage = "Saved $key",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure ->
|
is NetworkResult.Failure -> {
|
||||||
state.copy(
|
state.copy(
|
||||||
savingEnvKey = null,
|
savingEnvKey = null,
|
||||||
toastMessage = "Failed to save $key: ${result.error.message}",
|
toastMessage = "Failed to save $key: ${result.error.message}",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,19 +205,21 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
|
|||||||
val result = safeApiCall { ApiClient.hermesApi.deleteEnvVar(EnvVarDeleteRequest(key)) }
|
val result = safeApiCall { ApiClient.hermesApi.deleteEnvVar(EnvVarDeleteRequest(key)) }
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success ->
|
is NetworkResult.Success -> {
|
||||||
state.copy(
|
state.copy(
|
||||||
deletingEnvKey = null,
|
deletingEnvKey = null,
|
||||||
config = state.config?.let { patchEnvIsSet(it, key, false) },
|
config = state.config?.let { patchEnvIsSet(it, key, false) },
|
||||||
revealedValues = state.revealedValues - key,
|
revealedValues = state.revealedValues - key,
|
||||||
toastMessage = "Cleared $key",
|
toastMessage = "Cleared $key",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure ->
|
is NetworkResult.Failure -> {
|
||||||
state.copy(
|
state.copy(
|
||||||
deletingEnvKey = null,
|
deletingEnvKey = null,
|
||||||
toastMessage = "Failed to clear $key: ${result.error.message}",
|
toastMessage = "Failed to clear $key: ${result.error.message}",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,11 +230,13 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
|
|||||||
val result = safeApiCall { ApiClient.hermesApi.revealEnvVar(EnvVarRevealRequest(key)) }
|
val result = safeApiCall { ApiClient.hermesApi.revealEnvVar(EnvVarRevealRequest(key)) }
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success ->
|
is NetworkResult.Success -> {
|
||||||
state.copy(revealedValues = state.revealedValues + (key to result.data.value))
|
state.copy(revealedValues = state.revealedValues + (key to result.data.value))
|
||||||
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure ->
|
is NetworkResult.Failure -> {
|
||||||
state.copy(toastMessage = "Failed to reveal $key: ${result.error.message}")
|
state.copy(toastMessage = "Failed to reveal $key: ${result.error.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ class KanbanEventsClientTest {
|
|||||||
assertEquals("t-1", event.taskId)
|
assertEquals("t-1", event.taskId)
|
||||||
assertEquals("r-9", event.runId)
|
assertEquals("r-9", event.runId)
|
||||||
assertEquals("status", event.kind)
|
assertEquals("status", event.kind)
|
||||||
assertEquals("done", event.payload?.get("status")?.jsonPrimitive?.content)
|
assertEquals(
|
||||||
|
"done",
|
||||||
|
event.payload
|
||||||
|
?.get("status")
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.content,
|
||||||
|
)
|
||||||
assertEquals(1710000000L, event.createdAt)
|
assertEquals(1710000000L, event.createdAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,12 +143,16 @@ class BotsViewModelTest {
|
|||||||
|
|
||||||
// Toggle show hidden (sorted: default (active), scout (worker active now), reviewer (canonical last_active))
|
// Toggle show hidden (sorted: default (active), scout (worker active now), reviewer (canonical last_active))
|
||||||
viewModel.toggleShowHidden()
|
viewModel.toggleShowHidden()
|
||||||
val displayedWithHidden = viewModel.uiState.value.displayProfiles.map { it.name }
|
val displayedWithHidden =
|
||||||
|
viewModel.uiState.value.displayProfiles
|
||||||
|
.map { it.name }
|
||||||
assertEquals(listOf("default", "reviewer", "scout"), displayedWithHidden)
|
assertEquals(listOf("default", "reviewer", "scout"), displayedWithHidden)
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
viewModel.setSearchQuery("arxiv")
|
viewModel.setSearchQuery("arxiv")
|
||||||
val searchResults = viewModel.uiState.value.displayProfiles.map { it.name }
|
val searchResults =
|
||||||
|
viewModel.uiState.value.displayProfiles
|
||||||
|
.map { it.name }
|
||||||
assertEquals(listOf("scout"), searchResults)
|
assertEquals(listOf("scout"), searchResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,8 +234,16 @@ class BotsViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals(2, viewModel.uiState.value.allGroups.size)
|
assertEquals(2, viewModel.uiState.value.allGroups.size)
|
||||||
assertEquals("Dream Team", viewModel.uiState.value.allGroups[0].name)
|
assertEquals(
|
||||||
assertEquals(2, viewModel.uiState.value.allGroups[0].members.size)
|
"Dream Team",
|
||||||
|
viewModel.uiState.value.allGroups[0]
|
||||||
|
.name,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
2,
|
||||||
|
viewModel.uiState.value.allGroups[0]
|
||||||
|
.members.size,
|
||||||
|
)
|
||||||
|
|
||||||
var success = false
|
var success = false
|
||||||
viewModel.disbandGroupChat("Dream Team") { success = true }
|
viewModel.disbandGroupChat("Dream Team") { success = true }
|
||||||
|
|||||||
@@ -205,7 +205,9 @@ class GroupChatViewModelTest {
|
|||||||
testScheduler.runCurrent()
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
// State should contain user message
|
// State should contain user message
|
||||||
val userMsg = viewModel.uiState.value.messages.first()
|
val userMsg =
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.first()
|
||||||
assertTrue(userMsg.isUser)
|
assertTrue(userMsg.isUser)
|
||||||
assertEquals("@scout what's the status?", userMsg.text)
|
assertEquals("@scout what's the status?", userMsg.text)
|
||||||
|
|
||||||
@@ -213,7 +215,9 @@ class GroupChatViewModelTest {
|
|||||||
eventsFlow.emit(WsEvent.MessageToken(token = "Building ", sessionId = "session-scout-1"))
|
eventsFlow.emit(WsEvent.MessageToken(token = "Building ", sessionId = "session-scout-1"))
|
||||||
testScheduler.runCurrent()
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
val streamingMsg = viewModel.uiState.value.messages.last()
|
val streamingMsg =
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.last()
|
||||||
assertFalse(streamingMsg.isUser)
|
assertFalse(streamingMsg.isUser)
|
||||||
assertEquals("scout", streamingMsg.senderName)
|
assertEquals("scout", streamingMsg.senderName)
|
||||||
assertEquals("Building ", streamingMsg.text)
|
assertEquals("Building ", streamingMsg.text)
|
||||||
@@ -222,7 +226,9 @@ class GroupChatViewModelTest {
|
|||||||
eventsFlow.emit(WsEvent.MessageToken(token = "the APK...", sessionId = "session-scout-1"))
|
eventsFlow.emit(WsEvent.MessageToken(token = "the APK...", sessionId = "session-scout-1"))
|
||||||
testScheduler.runCurrent()
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
val updatedStreaming = viewModel.uiState.value.messages.last()
|
val updatedStreaming =
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.last()
|
||||||
assertEquals("Building the APK...", updatedStreaming.text)
|
assertEquals("Building the APK...", updatedStreaming.text)
|
||||||
|
|
||||||
// Complete turn
|
// Complete turn
|
||||||
@@ -285,7 +291,11 @@ class GroupChatViewModelTest {
|
|||||||
testScheduler.runCurrent()
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
assertEquals(2, viewModel.uiState.value.messages.size)
|
assertEquals(2, viewModel.uiState.value.messages.size)
|
||||||
assertEquals("scout", viewModel.uiState.value.messages[1].senderName)
|
assertEquals(
|
||||||
|
"scout",
|
||||||
|
viewModel.uiState.value.messages[1]
|
||||||
|
.senderName,
|
||||||
|
)
|
||||||
|
|
||||||
// Continuation pass triggers coder
|
// Continuation pass triggers coder
|
||||||
eventsFlow.emit(
|
eventsFlow.emit(
|
||||||
@@ -345,7 +355,11 @@ class GroupChatViewModelTest {
|
|||||||
|
|
||||||
// Because lease is held by desktop_client_99, mobile only posts the user message and yields
|
// Because lease is held by desktop_client_99, mobile only posts the user message and yields
|
||||||
assertEquals(1, viewModel.uiState.value.messages.size)
|
assertEquals(1, viewModel.uiState.value.messages.size)
|
||||||
assertTrue(viewModel.uiState.value.messages.first().isUser)
|
assertTrue(
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.first()
|
||||||
|
.isUser,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -452,7 +466,9 @@ class GroupChatViewModelTest {
|
|||||||
)
|
)
|
||||||
testScheduler.runCurrent()
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
val msgWithTool = viewModel.uiState.value.messages.last()
|
val msgWithTool =
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.last()
|
||||||
assertEquals(1, msgWithTool.toolCalls.size)
|
assertEquals(1, msgWithTool.toolCalls.size)
|
||||||
val toolCall = msgWithTool.toolCalls.first()
|
val toolCall = msgWithTool.toolCalls.first()
|
||||||
assertEquals("terminal", toolCall.name)
|
assertEquals("terminal", toolCall.name)
|
||||||
@@ -469,7 +485,9 @@ class GroupChatViewModelTest {
|
|||||||
)
|
)
|
||||||
testScheduler.runCurrent()
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
val msgAfterToolDone = viewModel.uiState.value.messages.last()
|
val msgAfterToolDone =
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.last()
|
||||||
assertFalse(msgAfterToolDone.toolCalls.first().isRunning)
|
assertFalse(msgAfterToolDone.toolCalls.first().isRunning)
|
||||||
|
|
||||||
// Complete message
|
// Complete message
|
||||||
@@ -481,7 +499,9 @@ class GroupChatViewModelTest {
|
|||||||
)
|
)
|
||||||
testScheduler.runCurrent()
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
val finalMsg = viewModel.uiState.value.messages.last()
|
val finalMsg =
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.last()
|
||||||
assertEquals("Current time is Sat Aug 29", finalMsg.text)
|
assertEquals("Current time is Sat Aug 29", finalMsg.text)
|
||||||
assertEquals(1, finalMsg.toolCalls.size)
|
assertEquals(1, finalMsg.toolCalls.size)
|
||||||
assertFalse(finalMsg.toolCalls.first().isRunning)
|
assertFalse(finalMsg.toolCalls.first().isRunning)
|
||||||
|
|||||||
@@ -158,7 +158,13 @@ class ChatInputPolicyTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun applyMention_insertsHandleAndAdvancesCursor() {
|
fun applyMention_insertsHandleAndAdvancesCursor() {
|
||||||
val initial = TextFieldValue("ask @sc", selection = androidx.compose.ui.text.TextRange(7))
|
val initial =
|
||||||
|
TextFieldValue(
|
||||||
|
"ask @sc",
|
||||||
|
selection =
|
||||||
|
androidx.compose.ui.text
|
||||||
|
.TextRange(7),
|
||||||
|
)
|
||||||
val updated = ChatInputPolicy.applyMention(initial, "scout")
|
val updated = ChatInputPolicy.applyMention(initial, "scout")
|
||||||
assertEquals("ask @scout ", updated.text)
|
assertEquals("ask @scout ", updated.text)
|
||||||
assertEquals(11, updated.selection.end)
|
assertEquals(11, updated.selection.end)
|
||||||
|
|||||||
@@ -369,12 +369,13 @@ class ChatToolDedupeTest {
|
|||||||
var stream = StreamingState()
|
var stream = StreamingState()
|
||||||
stream = ChatWsEventReducer.reduce(s, stream, WsEvent.MessageStart("session-1"), "session-1").streamingState
|
stream = ChatWsEventReducer.reduce(s, stream, WsEvent.MessageStart("session-1"), "session-1").streamingState
|
||||||
stream =
|
stream =
|
||||||
ChatWsEventReducer.reduce(
|
ChatWsEventReducer
|
||||||
s,
|
.reduce(
|
||||||
stream,
|
s,
|
||||||
WsEvent.ReasoningDelta("thinking about meow", "session-1"),
|
stream,
|
||||||
"session-1",
|
WsEvent.ReasoningDelta("thinking about meow", "session-1"),
|
||||||
).streamingState
|
"session-1",
|
||||||
|
).streamingState
|
||||||
var r =
|
var r =
|
||||||
ChatWsEventReducer.reduce(
|
ChatWsEventReducer.reduce(
|
||||||
s,
|
s,
|
||||||
|
|||||||
@@ -159,7 +159,12 @@ class ChatUpdateCommandTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertTrue("confirm dialog should open", vm.uiState.value.updateConfirmOpen)
|
assertTrue("confirm dialog should open", vm.uiState.value.updateConfirmOpen)
|
||||||
assertEquals("/update", vm.uiState.value.messages.last().content)
|
assertEquals(
|
||||||
|
"/update",
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.last()
|
||||||
|
.content,
|
||||||
|
)
|
||||||
|
|
||||||
// The whole point of the fix: NOTHING goes over the wire.
|
// The whole point of the fix: NOTHING goes over the wire.
|
||||||
verify(exactly = 0) {
|
verify(exactly = 0) {
|
||||||
|
|||||||
@@ -513,7 +513,10 @@ class ChatViewModelTest {
|
|||||||
|
|
||||||
// A blocklisted command can never dispatch — it must not climb
|
// A blocklisted command can never dispatch — it must not climb
|
||||||
// the autocomplete ranking either.
|
// the autocomplete ranking either.
|
||||||
assertTrue(viewModel.uiState.value.slashUsageCounts.isEmpty())
|
assertTrue(
|
||||||
|
viewModel.uiState.value.slashUsageCounts
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
verify(exactly = 0) {
|
verify(exactly = 0) {
|
||||||
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
|
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1036,10 @@ class ChatViewModelTest {
|
|||||||
viewModel.uiState.value.messages[1]
|
viewModel.uiState.value.messages[1]
|
||||||
.role,
|
.role,
|
||||||
)
|
)
|
||||||
assertFalse(viewModel.uiState.value.messages[1].isStreaming)
|
assertFalse(
|
||||||
|
viewModel.uiState.value.messages[1]
|
||||||
|
.isStreaming,
|
||||||
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
MessageRole.TOOL,
|
MessageRole.TOOL,
|
||||||
viewModel.uiState.value.messages[2]
|
viewModel.uiState.value.messages[2]
|
||||||
@@ -1041,7 +1047,10 @@ class ChatViewModelTest {
|
|||||||
)
|
)
|
||||||
assertNull(viewModel.streamingState.value.streamingMessage)
|
assertNull(viewModel.streamingState.value.streamingMessage)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf(viewModel.uiState.value.messages[1].id),
|
listOf(
|
||||||
|
viewModel.uiState.value.messages[1]
|
||||||
|
.id,
|
||||||
|
),
|
||||||
viewModel.streamingState.value.sealedOrphanIds,
|
viewModel.streamingState.value.sealedOrphanIds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1050,7 +1059,8 @@ class ChatViewModelTest {
|
|||||||
mockEventsFlow.emit(WsEvent.MessageComplete("Calculating sum = 4", sessionId))
|
mockEventsFlow.emit(WsEvent.MessageComplete("Calculating sum = 4", sessionId))
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
val assistant =
|
val assistant =
|
||||||
viewModel.uiState.value.messages.filter { it.role == MessageRole.ASSISTANT }
|
viewModel.uiState.value.messages
|
||||||
|
.filter { it.role == MessageRole.ASSISTANT }
|
||||||
assertEquals(2, assistant.size)
|
assertEquals(2, assistant.size)
|
||||||
assertEquals("Calculating sum", assistant[0].content)
|
assertEquals("Calculating sum", assistant[0].content)
|
||||||
assertEquals(" = 4", assistant[1].content)
|
assertEquals(" = 4", assistant[1].content)
|
||||||
@@ -1573,7 +1583,8 @@ class ChatViewModelTest {
|
|||||||
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
||||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0),
|
com.m57.hermescontrol.data.model
|
||||||
|
.SessionListResponse(sessions = emptyList(), total = 0),
|
||||||
)
|
)
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApi.getSessionMessages(
|
mockApi.getSessionMessages(
|
||||||
@@ -1623,7 +1634,8 @@ class ChatViewModelTest {
|
|||||||
|
|
||||||
val empty =
|
val empty =
|
||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()),
|
com.m57.hermescontrol.data.model
|
||||||
|
.SessionMessagesResponse(messages = emptyList()),
|
||||||
)
|
)
|
||||||
messagesA.complete(empty)
|
messagesA.complete(empty)
|
||||||
messagesB.complete(empty)
|
messagesB.complete(empty)
|
||||||
@@ -1641,7 +1653,8 @@ class ChatViewModelTest {
|
|||||||
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
||||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0),
|
com.m57.hermescontrol.data.model
|
||||||
|
.SessionListResponse(sessions = emptyList(), total = 0),
|
||||||
)
|
)
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApi.getSessionMessages(
|
mockApi.getSessionMessages(
|
||||||
@@ -1698,7 +1711,11 @@ class ChatViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals("session-b", viewModel.uiState.value.currentSessionId)
|
assertEquals("session-b", viewModel.uiState.value.currentSessionId)
|
||||||
assertEquals(listOf("message-b"), viewModel.uiState.value.messages.map { it.content })
|
assertEquals(
|
||||||
|
listOf("message-b"),
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.map { it.content },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1927,7 +1944,8 @@ class ChatViewModelTest {
|
|||||||
sessionIds.forEach { sessionId ->
|
sessionIds.forEach { sessionId ->
|
||||||
coEvery { mockApi.getSessionMessages(sessionId, any(), any(), any(), any()) } returns
|
coEvery { mockApi.getSessionMessages(sessionId, any(), any(), any(), any()) } returns
|
||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()),
|
com.m57.hermescontrol.data.model
|
||||||
|
.SessionMessagesResponse(messages = emptyList()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1956,7 +1974,11 @@ class ChatViewModelTest {
|
|||||||
// Warm cache painted and survived the exhausted retry cycle — the
|
// Warm cache painted and survived the exhausted retry cycle — the
|
||||||
// screen never went blank, and the user gets history + an error.
|
// screen never went blank, and the user gets history + an error.
|
||||||
assertEquals(1, viewModel.uiState.value.messages.size)
|
assertEquals(1, viewModel.uiState.value.messages.size)
|
||||||
assertEquals("Cached hello", viewModel.uiState.value.messages[0].content)
|
assertEquals(
|
||||||
|
"Cached hello",
|
||||||
|
viewModel.uiState.value.messages[0]
|
||||||
|
.content,
|
||||||
|
)
|
||||||
assertFalse(viewModel.uiState.value.isLoading)
|
assertFalse(viewModel.uiState.value.isLoading)
|
||||||
assertNotNull("resumeError must be set after retries exhaust", viewModel.uiState.value.resumeError)
|
assertNotNull("resumeError must be set after retries exhaust", viewModel.uiState.value.resumeError)
|
||||||
assertFalse(viewModel.uiState.value.isResumeRetrying)
|
assertFalse(viewModel.uiState.value.isResumeRetrying)
|
||||||
@@ -2305,7 +2327,8 @@ class ChatViewModelTest {
|
|||||||
assertEquals("session-new", viewModel.uiState.value.currentSessionId)
|
assertEquals("session-new", viewModel.uiState.value.currentSessionId)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"recovery notice must be visible",
|
"recovery notice must be visible",
|
||||||
viewModel.uiState.value.messages.any { it.content.contains("no longer available") },
|
viewModel.uiState.value.messages
|
||||||
|
.any { it.content.contains("no longer available") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2328,7 +2351,8 @@ class ChatViewModelTest {
|
|||||||
ApiClient.hermesApi.getSessionMessages(capture(fetchedSessions), any(), any(), any(), any())
|
ApiClient.hermesApi.getSessionMessages(capture(fetchedSessions), any(), any(), any(), any())
|
||||||
} returns
|
} returns
|
||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()),
|
com.m57.hermescontrol.data.model
|
||||||
|
.SessionMessagesResponse(messages = emptyList()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// /fork sends session.branch keyed on the runtime id.
|
// /fork sends session.branch keyed on the runtime id.
|
||||||
@@ -2379,7 +2403,8 @@ class ChatViewModelTest {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()),
|
com.m57.hermescontrol.data.model
|
||||||
|
.SessionMessagesResponse(messages = emptyList()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3301,7 +3326,12 @@ class ChatViewModelTest {
|
|||||||
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||||
assertEquals(150, viewModel.uiState.value.messages.size)
|
assertEquals(150, viewModel.uiState.value.messages.size)
|
||||||
// Stable keys come from the server row id, not the from-end position.
|
// Stable keys come from the server row id, not the from-end position.
|
||||||
assertEquals("rest-session-456-150", viewModel.uiState.value.messages.last().id)
|
assertEquals(
|
||||||
|
"rest-session-456-150",
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.last()
|
||||||
|
.id,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -3444,7 +3474,10 @@ class ChatViewModelTest {
|
|||||||
// 160 rows: the 10 new ones appended, the existing 150 kept —
|
// 160 rows: the 10 new ones appended, the existing 150 kept —
|
||||||
// no duplicates, no dropped newest copy (stable row-id keys).
|
// no duplicates, no dropped newest copy (stable row-id keys).
|
||||||
assertEquals(160, viewModel.uiState.value.messages.size)
|
assertEquals(160, viewModel.uiState.value.messages.size)
|
||||||
assertTrue(viewModel.uiState.value.messages.any { it.id == "rest-session-456-160" })
|
assertTrue(
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.any { it.id == "rest-session-456-160" },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Attachment open (issue #724) ─────────────────────────────────────
|
// ── Attachment open (issue #724) ─────────────────────────────────────
|
||||||
@@ -3506,15 +3539,17 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
mockkObject(GatewayFileClient)
|
mockkObject(GatewayFileClient)
|
||||||
val cacheDir =
|
val cacheDir =
|
||||||
java.io.File(
|
java.io
|
||||||
System.getProperty("java.io.tmpdir"),
|
.File(
|
||||||
"gw_save_${System.nanoTime()}",
|
System.getProperty("java.io.tmpdir"),
|
||||||
).apply { mkdirs() }
|
"gw_save_${System.nanoTime()}",
|
||||||
|
).apply { mkdirs() }
|
||||||
every { app.cacheDir } returns cacheDir
|
every { app.cacheDir } returns cacheDir
|
||||||
every { app.applicationContext } returns app
|
every { app.applicationContext } returns app
|
||||||
every { app.getApplicationContext() } returns app
|
every { app.getApplicationContext() } returns app
|
||||||
val file =
|
val file =
|
||||||
java.io.File(System.getProperty("java.io.tmpdir"), "note_${System.nanoTime()}.txt")
|
java.io
|
||||||
|
.File(System.getProperty("java.io.tmpdir"), "note_${System.nanoTime()}.txt")
|
||||||
.apply { writeText("downloaded") }
|
.apply { writeText("downloaded") }
|
||||||
coEvery { GatewayFileClient.fetch("/tmp/note.txt", any()) } returns
|
coEvery { GatewayFileClient.fetch("/tmp/note.txt", any()) } returns
|
||||||
GatewayFileResult.Success(GatewayFile("note.txt", "text/plain", file))
|
GatewayFileResult.Success(GatewayFile("note.txt", "text/plain", file))
|
||||||
@@ -3552,10 +3587,11 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
mockkObject(GatewayFileClient)
|
mockkObject(GatewayFileClient)
|
||||||
val cacheDir =
|
val cacheDir =
|
||||||
java.io.File(
|
java.io
|
||||||
System.getProperty("java.io.tmpdir"),
|
.File(
|
||||||
"gw_save_${System.nanoTime()}",
|
System.getProperty("java.io.tmpdir"),
|
||||||
).apply { mkdirs() }
|
"gw_save_${System.nanoTime()}",
|
||||||
|
).apply { mkdirs() }
|
||||||
every { app.cacheDir } returns cacheDir
|
every { app.cacheDir } returns cacheDir
|
||||||
every { app.applicationContext } returns app
|
every { app.applicationContext } returns app
|
||||||
every { app.getApplicationContext() } returns app
|
every { app.getApplicationContext() } returns app
|
||||||
@@ -3590,10 +3626,11 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
mockkObject(GatewayFileClient)
|
mockkObject(GatewayFileClient)
|
||||||
val cacheDir =
|
val cacheDir =
|
||||||
java.io.File(
|
java.io
|
||||||
System.getProperty("java.io.tmpdir"),
|
.File(
|
||||||
"gw_save_${System.nanoTime()}",
|
System.getProperty("java.io.tmpdir"),
|
||||||
).apply { mkdirs() }
|
"gw_save_${System.nanoTime()}",
|
||||||
|
).apply { mkdirs() }
|
||||||
every { app.cacheDir } returns cacheDir
|
every { app.cacheDir } returns cacheDir
|
||||||
every { app.applicationContext } returns app
|
every { app.applicationContext } returns app
|
||||||
every { app.getApplicationContext() } returns app
|
every { app.getApplicationContext() } returns app
|
||||||
@@ -3606,7 +3643,8 @@ class ChatViewModelTest {
|
|||||||
GatewayFile(
|
GatewayFile(
|
||||||
"report.pdf",
|
"report.pdf",
|
||||||
"application/pdf",
|
"application/pdf",
|
||||||
java.io.File(System.getProperty("java.io.tmpdir"), "report_${System.nanoTime()}.pdf")
|
java.io
|
||||||
|
.File(System.getProperty("java.io.tmpdir"), "report_${System.nanoTime()}.pdf")
|
||||||
.apply { writeBytes(byteArrayOf(1)) },
|
.apply { writeBytes(byteArrayOf(1)) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -3625,7 +3663,11 @@ class ChatViewModelTest {
|
|||||||
|
|
||||||
verify(exactly = 0) { resolver.delete(any(), any(), any()) }
|
verify(exactly = 0) { resolver.delete(any(), any(), any()) }
|
||||||
assertNull(viewModel.uiState.value.savingAttachmentPath)
|
assertNull(viewModel.uiState.value.savingAttachmentPath)
|
||||||
assertTrue(viewModel.uiState.value.openError.orEmpty().startsWith("Could not save"))
|
assertTrue(
|
||||||
|
viewModel.uiState.value.openError
|
||||||
|
.orEmpty()
|
||||||
|
.startsWith("Could not save"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -3633,10 +3675,11 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
mockkObject(GatewayFileClient)
|
mockkObject(GatewayFileClient)
|
||||||
val cacheDir =
|
val cacheDir =
|
||||||
java.io.File(
|
java.io
|
||||||
System.getProperty("java.io.tmpdir"),
|
.File(
|
||||||
"gw_save_${System.nanoTime()}",
|
System.getProperty("java.io.tmpdir"),
|
||||||
).apply { mkdirs() }
|
"gw_save_${System.nanoTime()}",
|
||||||
|
).apply { mkdirs() }
|
||||||
every { app.cacheDir } returns cacheDir
|
every { app.cacheDir } returns cacheDir
|
||||||
every { app.applicationContext } returns app
|
every { app.applicationContext } returns app
|
||||||
every { app.getApplicationContext() } returns app
|
every { app.getApplicationContext() } returns app
|
||||||
@@ -3675,10 +3718,11 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
mockkObject(GatewayFileClient)
|
mockkObject(GatewayFileClient)
|
||||||
val cacheDir =
|
val cacheDir =
|
||||||
java.io.File(
|
java.io
|
||||||
System.getProperty("java.io.tmpdir"),
|
.File(
|
||||||
"gw_open_${System.nanoTime()}",
|
System.getProperty("java.io.tmpdir"),
|
||||||
).apply { mkdirs() }
|
"gw_open_${System.nanoTime()}",
|
||||||
|
).apply { mkdirs() }
|
||||||
every { app.cacheDir } returns cacheDir
|
every { app.cacheDir } returns cacheDir
|
||||||
every { app.applicationContext } returns app
|
every { app.applicationContext } returns app
|
||||||
every { app.getApplicationContext() } returns app
|
every { app.getApplicationContext() } returns app
|
||||||
@@ -3711,10 +3755,11 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
mockkObject(GatewayFileClient)
|
mockkObject(GatewayFileClient)
|
||||||
val cacheDir =
|
val cacheDir =
|
||||||
java.io.File(
|
java.io
|
||||||
System.getProperty("java.io.tmpdir"),
|
.File(
|
||||||
"gw_open_${System.nanoTime()}",
|
System.getProperty("java.io.tmpdir"),
|
||||||
).apply { mkdirs() }
|
"gw_open_${System.nanoTime()}",
|
||||||
|
).apply { mkdirs() }
|
||||||
every { app.cacheDir } returns cacheDir
|
every { app.cacheDir } returns cacheDir
|
||||||
every { app.applicationContext } returns app
|
every { app.applicationContext } returns app
|
||||||
every { app.getApplicationContext() } returns app
|
every { app.getApplicationContext() } returns app
|
||||||
@@ -3743,7 +3788,11 @@ class ChatViewModelTest {
|
|||||||
|
|
||||||
// …and cleared in all outcomes.
|
// …and cleared in all outcomes.
|
||||||
assertNull(vm.uiState.value.openingAttachmentPath)
|
assertNull(vm.uiState.value.openingAttachmentPath)
|
||||||
assertTrue(vm.uiState.value.openError.orEmpty().contains("big.pdf"))
|
assertTrue(
|
||||||
|
vm.uiState.value.openError
|
||||||
|
.orEmpty()
|
||||||
|
.contains("big.pdf"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -3763,7 +3812,10 @@ class ChatViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Optimistic UI state has the user message immediately
|
// Optimistic UI state has the user message immediately
|
||||||
assertTrue(vm.uiState.value.messages.any { it.content == "My super important long prompt" })
|
assertTrue(
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.any { it.content == "My super important long prompt" },
|
||||||
|
)
|
||||||
assertTrue(vm.uiState.value.isAgentTyping)
|
assertTrue(vm.uiState.value.isAgentTyping)
|
||||||
// But wsClient.sendMessage not dispatched yet because session_id was null
|
// But wsClient.sendMessage not dispatched yet because session_id was null
|
||||||
assertTrue(sentPrompts.isEmpty())
|
assertTrue(sentPrompts.isEmpty())
|
||||||
@@ -3781,7 +3833,10 @@ class ChatViewModelTest {
|
|||||||
// Prompt should be dispatched automatically!
|
// Prompt should be dispatched automatically!
|
||||||
assertEquals(listOf("My super important long prompt"), sentPrompts)
|
assertEquals(listOf("My super important long prompt"), sentPrompts)
|
||||||
assertEquals("session-storage-969", vm.uiState.value.currentSessionId)
|
assertEquals("session-storage-969", vm.uiState.value.currentSessionId)
|
||||||
assertTrue(vm.uiState.value.messages.any { it.content == "My super important long prompt" })
|
assertTrue(
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.any { it.content == "My super important long prompt" },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -3806,7 +3861,10 @@ class ChatViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Optimistic message in UI
|
// Optimistic message in UI
|
||||||
assertTrue(vm.uiState.value.messages.any { it.content == "Prompt typed during reconnect" })
|
assertTrue(
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.any { it.content == "Prompt typed during reconnect" },
|
||||||
|
)
|
||||||
|
|
||||||
// Complete the new SESSION_CREATE RPC
|
// Complete the new SESSION_CREATE RPC
|
||||||
mockEventsFlow.emit(
|
mockEventsFlow.emit(
|
||||||
@@ -3842,6 +3900,9 @@ class ChatViewModelTest {
|
|||||||
|
|
||||||
assertFalse(vm.uiState.value.isAgentTyping)
|
assertFalse(vm.uiState.value.isAgentTyping)
|
||||||
assertNotNull(vm.uiState.value.errorMessage)
|
assertNotNull(vm.uiState.value.errorMessage)
|
||||||
assertTrue(vm.uiState.value.errorMessage!!.contains("Backend exploded"))
|
assertTrue(
|
||||||
|
vm.uiState.value.errorMessage!!
|
||||||
|
.contains("Backend exploded"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,7 +421,12 @@ class ChatWsEventReducerTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(1, result.state.messages.size)
|
assertEquals(1, result.state.messages.size)
|
||||||
assertEquals("complete", result.state.messages.single().content)
|
assertEquals(
|
||||||
|
"complete",
|
||||||
|
result.state.messages
|
||||||
|
.single()
|
||||||
|
.content,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -556,12 +561,13 @@ class ChatWsEventReducerTest {
|
|||||||
var reasoningState = start.streamingState
|
var reasoningState = start.streamingState
|
||||||
for (token in reasoningTokens) {
|
for (token in reasoningTokens) {
|
||||||
reasoningState =
|
reasoningState =
|
||||||
ChatWsEventReducer.reduce(
|
ChatWsEventReducer
|
||||||
state,
|
.reduce(
|
||||||
reasoningState,
|
state,
|
||||||
WsEvent.ReasoningDelta(token, "session-1"),
|
reasoningState,
|
||||||
"session-1",
|
WsEvent.ReasoningDelta(token, "session-1"),
|
||||||
).streamingState
|
"session-1",
|
||||||
|
).streamingState
|
||||||
}
|
}
|
||||||
assertEquals(true, reasoningState.isReasoning)
|
assertEquals(true, reasoningState.isReasoning)
|
||||||
assertEquals("The user just said \"run echo hi\". Let me run it.", reasoningState.reasoningText)
|
assertEquals("The user just said \"run echo hi\". Let me run it.", reasoningState.reasoningText)
|
||||||
@@ -578,8 +584,18 @@ class ChatWsEventReducerTest {
|
|||||||
"session-1",
|
"session-1",
|
||||||
)
|
)
|
||||||
assertEquals(1, result.state.messages.size)
|
assertEquals(1, result.state.messages.size)
|
||||||
assertEquals(MessageRole.TOOL, result.state.messages.first().role)
|
assertEquals(
|
||||||
assertEquals("terminal", result.state.messages.first().toolName)
|
MessageRole.TOOL,
|
||||||
|
result.state.messages
|
||||||
|
.first()
|
||||||
|
.role,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"terminal",
|
||||||
|
result.state.messages
|
||||||
|
.first()
|
||||||
|
.toolName,
|
||||||
|
)
|
||||||
// Issue #771: streaming message + reasoning survive the tool call
|
// Issue #771: streaming message + reasoning survive the tool call
|
||||||
assertEquals(true, result.streamingState.streamingMessage?.isStreaming)
|
assertEquals(true, result.streamingState.streamingMessage?.isStreaming)
|
||||||
assertEquals("The user just said \"run echo hi\". Let me run it.", result.streamingState.reasoningText)
|
assertEquals("The user just said \"run echo hi\". Let me run it.", result.streamingState.reasoningText)
|
||||||
@@ -592,7 +608,12 @@ class ChatWsEventReducerTest {
|
|||||||
WsEvent.ToolComplete("terminal", mapOf("output" to "hi"), "session-1"),
|
WsEvent.ToolComplete("terminal", mapOf("output" to "hi"), "session-1"),
|
||||||
"session-1",
|
"session-1",
|
||||||
)
|
)
|
||||||
assertEquals(ToolStatus.COMPLETED, result.state.messages.first().toolStatus)
|
assertEquals(
|
||||||
|
ToolStatus.COMPLETED,
|
||||||
|
result.state.messages
|
||||||
|
.first()
|
||||||
|
.toolStatus,
|
||||||
|
)
|
||||||
|
|
||||||
// 5. reasoning.available — authoritative full-trace fill
|
// 5. reasoning.available — authoritative full-trace fill
|
||||||
val fullReasoning = "The user just said \"run echo hi\". That's a simple terminal command. Let me just run it."
|
val fullReasoning = "The user just said \"run echo hi\". That's a simple terminal command. Let me just run it."
|
||||||
@@ -683,7 +704,12 @@ class ChatWsEventReducerTest {
|
|||||||
"session-1",
|
"session-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("payload reasoning", result.state.messages.last().reasoningText)
|
assertEquals(
|
||||||
|
"payload reasoning",
|
||||||
|
result.state.messages
|
||||||
|
.last()
|
||||||
|
.reasoningText,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -223,7 +223,8 @@ class SlashCommandDispatchRpcTest {
|
|||||||
// The optimistic bubble shows the queued TEXT (prefix stripped) so
|
// The optimistic bubble shows the queued TEXT (prefix stripped) so
|
||||||
// the transcript sync dedupes it against the server echo.
|
// the transcript sync dedupes it against the server echo.
|
||||||
val userBubble =
|
val userBubble =
|
||||||
vm.uiState.value.messages.lastOrNull { it.role == MessageRole.USER }
|
vm.uiState.value.messages
|
||||||
|
.lastOrNull { it.role == MessageRole.USER }
|
||||||
assertEquals("do the thing", userBubble?.content)
|
assertEquals("do the thing", userBubble?.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +248,9 @@ class SlashCommandDispatchRpcTest {
|
|||||||
vm.sendMessage("/queue")
|
vm.sendMessage("/queue")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
val last = vm.uiState.value.messages.lastOrNull()
|
val last =
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.lastOrNull()
|
||||||
assertEquals("usage: /queue <prompt>", last?.content)
|
assertEquals("usage: /queue <prompt>", last?.content)
|
||||||
assertEquals(0, sendCalls)
|
assertEquals(0, sendCalls)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
@@ -628,7 +631,9 @@ class SlashCommandDispatchRpcTest {
|
|||||||
vm.sendMessage("/focus on")
|
vm.sendMessage("/focus on")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
val last = vm.uiState.value.messages.lastOrNull()
|
val last =
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.lastOrNull()
|
||||||
assertEquals("Focus view: ON (tool progress pinned off)", last?.content)
|
assertEquals("Focus view: ON (tool progress pinned off)", last?.content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +202,10 @@ class ActionProgressControllerTest {
|
|||||||
tick()
|
tick()
|
||||||
|
|
||||||
assertEquals(ActionProgressPhase.SUCCEEDED, controller.state.value.phase)
|
assertEquals(ActionProgressPhase.SUCCEEDED, controller.state.value.phase)
|
||||||
assertTrue(controller.state.value.trailingLines.isEmpty())
|
assertTrue(
|
||||||
|
controller.state.value.trailingLines
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
|
||||||
controller.pushTrailingLines(listOf("Outcome: success", "Version: 0.20.4 → 0.20.5"))
|
controller.pushTrailingLines(listOf("Outcome: success", "Version: 0.20.4 → 0.20.5"))
|
||||||
|
|
||||||
@@ -216,7 +219,10 @@ class ActionProgressControllerTest {
|
|||||||
val controller = controller()
|
val controller = controller()
|
||||||
controller.open()
|
controller.open()
|
||||||
controller.pushTrailingLines(emptyList())
|
controller.pushTrailingLines(emptyList())
|
||||||
assertTrue(controller.state.value.trailingLines.isEmpty())
|
assertTrue(
|
||||||
|
controller.state.value.trailingLines
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -204,7 +204,9 @@ class CronJobsViewModelTest {
|
|||||||
vm.saveEditor()
|
vm.saveEditor()
|
||||||
settle()
|
settle()
|
||||||
|
|
||||||
val toast = vm.uiState.value.editorState.toastMessage.orEmpty()
|
val toast =
|
||||||
|
vm.uiState.value.editorState.toastMessage
|
||||||
|
.orEmpty()
|
||||||
assertTrue(toast, toast.contains("invalid time '25:00'"))
|
assertTrue(toast, toast.contains("invalid time '25:00'"))
|
||||||
assertFalse(vm.uiState.value.editorState.isSaving)
|
assertFalse(vm.uiState.value.editorState.isSaving)
|
||||||
}
|
}
|
||||||
@@ -394,7 +396,11 @@ class CronJobsViewModelTest {
|
|||||||
|
|
||||||
coVerify { mockApi.deleteCronJob("j5") }
|
coVerify { mockApi.deleteCronJob("j5") }
|
||||||
assertTrue(vm.uiState.value.editorState.isOpen)
|
assertTrue(vm.uiState.value.editorState.isOpen)
|
||||||
assertTrue(vm.uiState.value.editorState.toastMessage.orEmpty().contains("Failed to save"))
|
assertTrue(
|
||||||
|
vm.uiState.value.editorState.toastMessage
|
||||||
|
.orEmpty()
|
||||||
|
.contains("Failed to save"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -549,7 +555,9 @@ class CronJobsViewModelTest {
|
|||||||
vm.loadCronJobs()
|
vm.loadCronJobs()
|
||||||
settle()
|
settle()
|
||||||
|
|
||||||
val job = vm.uiState.value.jobs.first()
|
val job =
|
||||||
|
vm.uiState.value.jobs
|
||||||
|
.first()
|
||||||
assertNotNull(job.last_fire_error)
|
assertNotNull(job.last_fire_error)
|
||||||
assertEquals("gateway unreachable", job.last_fire_error?.detail)
|
assertEquals("gateway unreachable", job.last_fire_error?.detail)
|
||||||
assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at)
|
assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at)
|
||||||
|
|||||||
@@ -222,7 +222,12 @@ class KanbanViewModelTest {
|
|||||||
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit)
|
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit)
|
||||||
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
|
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
|
||||||
settle()
|
settle()
|
||||||
assertEquals("ready", vm.uiState.value.tasks.first { it.id == "t1" }.status)
|
assertEquals(
|
||||||
|
"ready",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first { it.id == "t1" }
|
||||||
|
.status,
|
||||||
|
)
|
||||||
coVerify { mockApi.updateKanbanTask("t1", mapOf("status" to "ready")) }
|
coVerify { mockApi.updateKanbanTask("t1", mapOf("status" to "ready")) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,8 +239,16 @@ class KanbanViewModelTest {
|
|||||||
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.error(409, "".toResponseBody())
|
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.error(409, "".toResponseBody())
|
||||||
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
|
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
|
||||||
settle()
|
settle()
|
||||||
assertEquals("todo", vm.uiState.value.tasks.first { it.id == "t1" }.status)
|
assertEquals(
|
||||||
assertTrue(vm.uiState.value.toastMessage?.contains("Move failed") == true)
|
"todo",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first { it.id == "t1" }
|
||||||
|
.status,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("Move failed") == true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ class MemoryViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `load also fetches learning graph for self-improvement section`() {
|
fun `load also fetches learning graph for self-improvement section`() {
|
||||||
val graph = com.m57.hermescontrol.data.model.LearningGraphResponse(nodes = emptyList())
|
val graph =
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.LearningGraphResponse(nodes = emptyList())
|
||||||
coEvery { mockApi.getLearningGraph() } returns Response.success(graph)
|
coEvery { mockApi.getLearningGraph() } returns Response.success(graph)
|
||||||
val vm = MemoryViewModel()
|
val vm = MemoryViewModel()
|
||||||
vm.load()
|
vm.load()
|
||||||
@@ -100,7 +102,11 @@ class MemoryViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertNull(vm.uiState.value.memory)
|
assertNull(vm.uiState.value.memory)
|
||||||
assertTrue(vm.uiState.value.errorMessage.orEmpty().isNotBlank())
|
assertTrue(
|
||||||
|
vm.uiState.value.errorMessage
|
||||||
|
.orEmpty()
|
||||||
|
.isNotBlank(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -127,7 +133,11 @@ class MemoryViewModelTest {
|
|||||||
vm.resetMemory("memory")
|
vm.resetMemory("memory")
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to reset memory"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
.orEmpty()
|
||||||
|
.contains("Failed to reset memory"),
|
||||||
|
)
|
||||||
assertNull(vm.uiState.value.resetting)
|
assertNull(vm.uiState.value.resetting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,9 +97,21 @@ class PairingViewModelTest {
|
|||||||
assertFalse(state.isLoading)
|
assertFalse(state.isLoading)
|
||||||
assertNull(state.errorMessage)
|
assertNull(state.errorMessage)
|
||||||
assertEquals(1, state.pairing?.pending?.size)
|
assertEquals(1, state.pairing?.pending?.size)
|
||||||
assertEquals("bob", state.pairing?.pending?.first()?.userName)
|
assertEquals(
|
||||||
|
"bob",
|
||||||
|
state.pairing
|
||||||
|
?.pending
|
||||||
|
?.first()
|
||||||
|
?.userName,
|
||||||
|
)
|
||||||
assertEquals(1, state.pairing?.approved?.size)
|
assertEquals(1, state.pairing?.approved?.size)
|
||||||
assertEquals("alice", state.pairing?.approved?.first()?.userName)
|
assertEquals(
|
||||||
|
"alice",
|
||||||
|
state.pairing
|
||||||
|
?.approved
|
||||||
|
?.first()
|
||||||
|
?.userName,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -148,7 +160,10 @@ class PairingViewModelTest {
|
|||||||
assertEquals("discord", requestSlot.captured.platform)
|
assertEquals("discord", requestSlot.captured.platform)
|
||||||
assertEquals("u2", requestSlot.captured.userId)
|
assertEquals("u2", requestSlot.captured.userId)
|
||||||
coVerify(exactly = 1) { mockApi.getPairing() }
|
coVerify(exactly = 1) { mockApi.getPairing() }
|
||||||
assertTrue(vm.uiState.value.toastMessage?.contains("u2") == true)
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("u2") == true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+17
-3
@@ -229,7 +229,11 @@ class MemoryProviderDetailViewModelTest {
|
|||||||
vm.saveConfig()
|
vm.saveConfig()
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to save"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
.orEmpty()
|
||||||
|
.contains("Failed to save"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -242,7 +246,13 @@ class MemoryProviderDetailViewModelTest {
|
|||||||
val state = vm.uiState.value
|
val state = vm.uiState.value
|
||||||
assertEquals("Setup completed", state.toastMessage)
|
assertEquals("Setup completed", state.toastMessage)
|
||||||
assertTrue(state.setupResult?.ok == true)
|
assertTrue(state.setupResult?.ok == true)
|
||||||
assertEquals("installed", state.setupResult?.results?.first()?.status)
|
assertEquals(
|
||||||
|
"installed",
|
||||||
|
state.setupResult
|
||||||
|
?.results
|
||||||
|
?.first()
|
||||||
|
?.status,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -253,7 +263,11 @@ class MemoryProviderDetailViewModelTest {
|
|||||||
vm.runSetup()
|
vm.runSetup()
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Setup failed"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
.orEmpty()
|
||||||
|
.contains("Setup failed"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -136,7 +136,10 @@ class ProfilesViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
coVerify { mockApi.renameProfile("work", RenameProfileRequest("play")) }
|
coVerify { mockApi.renameProfile("work", RenameProfileRequest("play")) }
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("renamed"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("renamed"),
|
||||||
|
)
|
||||||
// reload happened
|
// reload happened
|
||||||
coVerify { mockApi.getProfiles() }
|
coVerify { mockApi.getProfiles() }
|
||||||
}
|
}
|
||||||
@@ -162,7 +165,10 @@ class ProfilesViewModelTest {
|
|||||||
vm.renameProfile("work", "play")
|
vm.renameProfile("work", "play")
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to rename"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("Failed to rename"),
|
||||||
|
)
|
||||||
assertFalse(vm.uiState.value.isLoading)
|
assertFalse(vm.uiState.value.isLoading)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +181,10 @@ class ProfilesViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
coVerify { mockApi.deleteProfile("work") }
|
coVerify { mockApi.deleteProfile("work") }
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("deleted"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("deleted"),
|
||||||
|
)
|
||||||
coVerify { mockApi.getProfiles() }
|
coVerify { mockApi.getProfiles() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +196,10 @@ class ProfilesViewModelTest {
|
|||||||
vm.deleteProfile("work")
|
vm.deleteProfile("work")
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to delete"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("Failed to delete"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -200,7 +212,10 @@ class ProfilesViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
coVerify { mockApi.describeProfileAuto("work", ProfileDescribeAutoRequest(overwrite = true)) }
|
coVerify { mockApi.describeProfileAuto("work", ProfileDescribeAutoRequest(overwrite = true)) }
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("Auto-described"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("Auto-described"),
|
||||||
|
)
|
||||||
assertFalse(vm.uiState.value.isAutoDescribing)
|
assertFalse(vm.uiState.value.isAutoDescribing)
|
||||||
coVerify { mockApi.getProfiles() }
|
coVerify { mockApi.getProfiles() }
|
||||||
}
|
}
|
||||||
@@ -214,7 +229,10 @@ class ProfilesViewModelTest {
|
|||||||
vm.autoDescribeProfile("work")
|
vm.autoDescribeProfile("work")
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("no aux client configured"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("no aux client configured"),
|
||||||
|
)
|
||||||
assertFalse(vm.uiState.value.isAutoDescribing)
|
assertFalse(vm.uiState.value.isAutoDescribing)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +259,10 @@ class ProfilesViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertNull(vm.uiState.value.setupCommand)
|
assertNull(vm.uiState.value.setupCommand)
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to fetch setup command"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("Failed to fetch setup command"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -265,7 +286,11 @@ class ProfilesViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals(1, vm.uiState.value.modelProviders.size)
|
assertEquals(1, vm.uiState.value.modelProviders.size)
|
||||||
assertEquals("gpt-4o", vm.uiState.value.modelProviders[0].models!![0])
|
assertEquals(
|
||||||
|
"gpt-4o",
|
||||||
|
vm.uiState.value.modelProviders[0]
|
||||||
|
.models!![0],
|
||||||
|
)
|
||||||
assertEquals(listOf(PinnedModel("openai", "gpt-4")), vm.uiState.value.modelPickerPinned)
|
assertEquals(listOf(PinnedModel("openai", "gpt-4")), vm.uiState.value.modelPickerPinned)
|
||||||
assertFalse(vm.uiState.value.isLoadingBuilderData)
|
assertFalse(vm.uiState.value.isLoadingBuilderData)
|
||||||
}
|
}
|
||||||
@@ -278,7 +303,10 @@ class ProfilesViewModelTest {
|
|||||||
vm.loadModelOptions()
|
vm.loadModelOptions()
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to load models"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("Failed to load models"),
|
||||||
|
)
|
||||||
assertNull(vm.uiState.value.errorMessage)
|
assertNull(vm.uiState.value.errorMessage)
|
||||||
assertFalse(vm.uiState.value.isLoadingBuilderData)
|
assertFalse(vm.uiState.value.isLoadingBuilderData)
|
||||||
}
|
}
|
||||||
@@ -292,7 +320,10 @@ class ProfilesViewModelTest {
|
|||||||
assertEquals(listOf(PinnedModel("openai", "gpt-4")), storedPinnedModels)
|
assertEquals(listOf(PinnedModel("openai", "gpt-4")), storedPinnedModels)
|
||||||
|
|
||||||
vm.togglePinModel("openai", "gpt-4")
|
vm.togglePinModel("openai", "gpt-4")
|
||||||
assertTrue(vm.uiState.value.modelPickerPinned.isEmpty())
|
assertTrue(
|
||||||
|
vm.uiState.value.modelPickerPinned
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
assertTrue(storedPinnedModels.isEmpty())
|
assertTrue(storedPinnedModels.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +336,10 @@ class ProfilesViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
coVerify { ProfileSwitchCoordinator.switchProfile("work") }
|
coVerify { ProfileSwitchCoordinator.switchProfile("work") }
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("Switched to profile work"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("Switched to profile work"),
|
||||||
|
)
|
||||||
coVerify { mockApi.getProfiles() }
|
coVerify { mockApi.getProfiles() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +354,10 @@ class ProfilesViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertNull(vm.uiState.value.activeProfileName)
|
assertNull(vm.uiState.value.activeProfileName)
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to switch profile"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("Failed to switch profile"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -339,7 +376,10 @@ class ProfilesViewModelTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("cloned successfully"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("cloned successfully"),
|
||||||
|
)
|
||||||
assertFalse(vm.uiState.value.isLoading)
|
assertFalse(vm.uiState.value.isLoading)
|
||||||
coVerify { mockApi.getProfiles() }
|
coVerify { mockApi.getProfiles() }
|
||||||
}
|
}
|
||||||
@@ -352,7 +392,10 @@ class ProfilesViewModelTest {
|
|||||||
vm.cloneProfile("default", "dev-copy")
|
vm.cloneProfile("default", "dev-copy")
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to clone profile"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("Failed to clone profile"),
|
||||||
|
)
|
||||||
assertFalse(vm.uiState.value.isLoading)
|
assertFalse(vm.uiState.value.isLoading)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,12 +421,20 @@ class ProfilesViewModelTest {
|
|||||||
vm.hideProfile("secret-bot")
|
vm.hideProfile("secret-bot")
|
||||||
assertEquals(listOf("secret-bot"), storedHiddenProfiles)
|
assertEquals(listOf("secret-bot"), storedHiddenProfiles)
|
||||||
assertTrue(vm.uiState.value.hasHiddenProfiles)
|
assertTrue(vm.uiState.value.hasHiddenProfiles)
|
||||||
assertEquals(listOf("default"), vm.uiState.value.displayProfiles.map { it.name })
|
assertEquals(
|
||||||
|
listOf("default"),
|
||||||
|
vm.uiState.value.displayProfiles
|
||||||
|
.map { it.name },
|
||||||
|
)
|
||||||
|
|
||||||
// Toggle show hidden
|
// Toggle show hidden
|
||||||
vm.toggleShowHidden()
|
vm.toggleShowHidden()
|
||||||
assertTrue(vm.uiState.value.showHidden)
|
assertTrue(vm.uiState.value.showHidden)
|
||||||
assertEquals(listOf("default", "secret-bot"), vm.uiState.value.displayProfiles.map { it.name })
|
assertEquals(
|
||||||
|
listOf("default", "secret-bot"),
|
||||||
|
vm.uiState.value.displayProfiles
|
||||||
|
.map { it.name },
|
||||||
|
)
|
||||||
|
|
||||||
// Unhide
|
// Unhide
|
||||||
vm.unhideProfile("secret-bot")
|
vm.unhideProfile("secret-bot")
|
||||||
|
|||||||
@@ -116,7 +116,11 @@ class SessionsViewModelTest {
|
|||||||
vm.loadSessions()
|
vm.loadSessions()
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals(listOf("s-1", "s-2"), vm.uiState.value.sessions.map { it.id })
|
assertEquals(
|
||||||
|
listOf("s-1", "s-2"),
|
||||||
|
vm.uiState.value.sessions
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
assertEquals(2, vm.uiState.value.total)
|
assertEquals(2, vm.uiState.value.total)
|
||||||
assertFalse(vm.uiState.value.hasMore)
|
assertFalse(vm.uiState.value.hasMore)
|
||||||
assertFalse(vm.uiState.value.isLoadingMore)
|
assertFalse(vm.uiState.value.isLoadingMore)
|
||||||
@@ -206,7 +210,11 @@ class SessionsViewModelTest {
|
|||||||
vm.loadSessions()
|
vm.loadSessions()
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals(listOf("old-pinned", "recent"), vm.uiState.value.sessions.map { it.id })
|
assertEquals(
|
||||||
|
listOf("old-pinned", "recent"),
|
||||||
|
vm.uiState.value.sessions
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -226,8 +234,17 @@ class SessionsViewModelTest {
|
|||||||
vm.togglePin("s-2")
|
vm.togglePin("s-2")
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals(listOf("s-2", "s-1"), vm.uiState.value.sessions.map { it.id })
|
assertEquals(
|
||||||
assertEquals(true, vm.uiState.value.sessions.first().pinned)
|
listOf("s-2", "s-1"),
|
||||||
|
vm.uiState.value.sessions
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
true,
|
||||||
|
vm.uiState.value.sessions
|
||||||
|
.first()
|
||||||
|
.pinned,
|
||||||
|
)
|
||||||
assertEquals("Session pinned", vm.uiState.value.toastMessage)
|
assertEquals("Session pinned", vm.uiState.value.toastMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,9 +266,19 @@ class SessionsViewModelTest {
|
|||||||
vm.togglePin("s-2")
|
vm.togglePin("s-2")
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals(listOf("s-1", "s-2"), vm.uiState.value.sessions.map { it.id })
|
assertEquals(
|
||||||
assertFalse(vm.uiState.value.sessions[1].pinned ?: false)
|
listOf("s-1", "s-2"),
|
||||||
|
vm.uiState.value.sessions
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
vm.uiState.value.sessions[1]
|
||||||
|
.pinned ?: false,
|
||||||
|
)
|
||||||
assertNotNull(vm.uiState.value.toastMessage)
|
assertNotNull(vm.uiState.value.toastMessage)
|
||||||
assertTrue(vm.uiState.value.toastMessage!!.contains("Pin failed"))
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage!!
|
||||||
|
.contains("Pin failed"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,11 @@ class SkillsViewModelTest {
|
|||||||
vm.loadHubSources()
|
vm.loadHubSources()
|
||||||
settle()
|
settle()
|
||||||
|
|
||||||
assertEquals(true, vm.uiState.value.hubSourcesError?.contains("Failed to load hub sources"))
|
assertEquals(
|
||||||
|
true,
|
||||||
|
vm.uiState.value.hubSourcesError
|
||||||
|
?.contains("Failed to load hub sources"),
|
||||||
|
)
|
||||||
assertEquals(false, vm.uiState.value.isHubSourcesLoading)
|
assertEquals(false, vm.uiState.value.isHubSourcesLoading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,15 @@ class ToolsetDetailViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
val state = vm.uiState.value
|
val state = vm.uiState.value
|
||||||
assertEquals(true, state.config?.providers?.get(1)?.envVars?.first { it.key == "KEY_B" }?.isSet)
|
assertEquals(
|
||||||
|
true,
|
||||||
|
state.config
|
||||||
|
?.providers
|
||||||
|
?.get(1)
|
||||||
|
?.envVars
|
||||||
|
?.first { it.key == "KEY_B" }
|
||||||
|
?.isSet,
|
||||||
|
)
|
||||||
assertEquals("Saved KEY_B", state.toastMessage)
|
assertEquals("Saved KEY_B", state.toastMessage)
|
||||||
assertNull(state.savingEnvKey)
|
assertNull(state.savingEnvKey)
|
||||||
}
|
}
|
||||||
@@ -196,7 +204,15 @@ class ToolsetDetailViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
val state = vm.uiState.value
|
val state = vm.uiState.value
|
||||||
assertEquals(false, state.config?.providers?.get(1)?.envVars?.first { it.key == "KEY_B" }?.isSet)
|
assertEquals(
|
||||||
|
false,
|
||||||
|
state.config
|
||||||
|
?.providers
|
||||||
|
?.get(1)
|
||||||
|
?.envVars
|
||||||
|
?.first { it.key == "KEY_B" }
|
||||||
|
?.isSet,
|
||||||
|
)
|
||||||
assertFalse(state.revealedValues.containsKey("KEY_B"))
|
assertFalse(state.revealedValues.containsKey("KEY_B"))
|
||||||
assertEquals("Cleared KEY_B", state.toastMessage)
|
assertEquals("Cleared KEY_B", state.toastMessage)
|
||||||
assertNull(state.deletingEnvKey)
|
assertNull(state.deletingEnvKey)
|
||||||
@@ -213,7 +229,10 @@ class ToolsetDetailViewModelTest {
|
|||||||
assertEquals("sekret", vm.uiState.value.revealedValues["KEY_B"])
|
assertEquals("sekret", vm.uiState.value.revealedValues["KEY_B"])
|
||||||
|
|
||||||
vm.hideEnvVar("KEY_B")
|
vm.hideEnvVar("KEY_B")
|
||||||
assertFalse(vm.uiState.value.revealedValues.containsKey("KEY_B"))
|
assertFalse(
|
||||||
|
vm.uiState.value.revealedValues
|
||||||
|
.containsKey("KEY_B"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -316,7 +335,10 @@ class ToolsetDetailViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertNull(vm.uiState.value.postSetup)
|
assertNull(vm.uiState.value.postSetup)
|
||||||
assertTrue(vm.uiState.value.toastMessage?.contains("spawn rejected") == true)
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("spawn rejected") == true,
|
||||||
|
)
|
||||||
coVerify(exactly = 0) { mockApi.getActionStatus(any()) }
|
coVerify(exactly = 0) { mockApi.getActionStatus(any()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user