106 files changed
+6059
-454
No files matched your search
@@ -21,11 +21,43 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Job 0: Detect changes — skips heavy Android build/tests for doc-only PRs
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
changes:
|
||||||
|
name: Detect Changes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 3
|
||||||
|
outputs:
|
||||||
|
code: ${{ steps.filter.outputs.code }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
- name: Paths Filter
|
||||||
|
uses: dorny/paths-filter@v4
|
||||||
|
id: filter
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
code:
|
||||||
|
- 'app/**'
|
||||||
|
- 'gradle/**'
|
||||||
|
- 'build.gradle.kts'
|
||||||
|
- 'settings.gradle.kts'
|
||||||
|
- 'gradle.properties'
|
||||||
|
- 'gradlew'
|
||||||
|
- 'gradlew.bat'
|
||||||
|
- '.github/workflows/android.yml'
|
||||||
|
- '.editorconfig'
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
# Job 1: ktlint (code style) — fastest feedback, runs in parallel.
|
# Job 1: ktlint (code style) — fastest feedback, runs in parallel.
|
||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
ktlint:
|
ktlint:
|
||||||
name: ktlint — Code Style
|
name: ktlint — Code Style
|
||||||
|
needs: [changes]
|
||||||
|
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
@@ -52,13 +84,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/
|
||||||
|
|
||||||
@@ -88,6 +120,8 @@ jobs:
|
|||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
android-lint:
|
android-lint:
|
||||||
name: Android Lint — Static Analysis
|
name: Android Lint — Static Analysis
|
||||||
|
needs: [changes]
|
||||||
|
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -125,6 +159,8 @@ jobs:
|
|||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
unit-tests:
|
unit-tests:
|
||||||
name: Unit & Integration Tests
|
name: Unit & Integration Tests
|
||||||
|
needs: [changes]
|
||||||
|
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -162,7 +198,8 @@ jobs:
|
|||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
build:
|
build:
|
||||||
name: Build Debug APK
|
name: Build Debug APK
|
||||||
needs: [ktlint, android-lint, unit-tests]
|
needs: [changes, ktlint, android-lint, unit-tests]
|
||||||
|
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -204,7 +241,8 @@ jobs:
|
|||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
release-compile:
|
release-compile:
|
||||||
name: Release Compile Check
|
name: Release Compile Check
|
||||||
needs: [ktlint, android-lint, unit-tests]
|
needs: [changes, ktlint, android-lint, unit-tests]
|
||||||
|
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -236,6 +274,8 @@ jobs:
|
|||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
instrumented-tests:
|
instrumented-tests:
|
||||||
name: Instrumented Tests
|
name: Instrumented Tests
|
||||||
|
needs: [changes]
|
||||||
|
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
@@ -299,7 +339,7 @@ jobs:
|
|||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
ci-summary:
|
ci-summary:
|
||||||
name: CI Summary
|
name: CI Summary
|
||||||
needs: [ktlint, android-lint, unit-tests, build, release-compile]
|
needs: [changes, ktlint, android-lint, unit-tests, build, release-compile]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 2
|
timeout-minutes: 2
|
||||||
if: always()
|
if: always()
|
||||||
@@ -323,7 +363,7 @@ jobs:
|
|||||||
name="${entry%%:*}"
|
name="${entry%%:*}"
|
||||||
result="${entry#*:}"
|
result="${entry#*:}"
|
||||||
echo "::notice::$name = $result"
|
echo "::notice::$name = $result"
|
||||||
if [[ "$result" != "success" ]]; then
|
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
|
||||||
FAILED=1
|
FAILED=1
|
||||||
FAILED_JOBS+="$name "
|
FAILED_JOBS+="$name "
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -3,8 +3,34 @@ name: "CodeQL Analysis"
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main", "dev" ]
|
branches: [ "main", "dev" ]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'docs/**'
|
||||||
|
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||||
|
- '.github/workflows/pr-labeler.yml'
|
||||||
|
- '.github/workflows/stale.yml'
|
||||||
|
- '.github/workflows/merge-conflict-detector.yml'
|
||||||
|
- 'LICENSE'
|
||||||
|
- 'fastlane/**'
|
||||||
|
- '.gitignore'
|
||||||
|
- '.editorconfig'
|
||||||
|
- '.envrc'
|
||||||
|
- '*.html'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main", "dev" ]
|
branches: [ "main", "dev" ]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'docs/**'
|
||||||
|
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||||
|
- '.github/workflows/pr-labeler.yml'
|
||||||
|
- '.github/workflows/stale.yml'
|
||||||
|
- '.github/workflows/merge-conflict-detector.yml'
|
||||||
|
- 'LICENSE'
|
||||||
|
- 'fastlane/**'
|
||||||
|
- '.gitignore'
|
||||||
|
- '.editorconfig'
|
||||||
|
- '.envrc'
|
||||||
|
- '*.html'
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '30 3 * * 1' # Runs at 03:30 UTC every Monday
|
- cron: '30 3 * * 1' # Runs at 03:30 UTC every Monday
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Hermes Mobile",
|
||||||
|
"slug": "hermes-mobile",
|
||||||
|
"project": "hermes-mobile",
|
||||||
|
"vcs": "git",
|
||||||
|
"branch": "dev",
|
||||||
|
"filemask": "app/src/main/res/values-*/strings.xml",
|
||||||
|
"template": "app/src/main/res/values/strings.xml",
|
||||||
|
"file_format": "android",
|
||||||
|
"new_base": "app/src/main/res/values/strings.xml",
|
||||||
|
"language_code_style": "android"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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.
|
||||||
|
|||||||
+11
-1
@@ -67,13 +67,23 @@ If you use AI coding tools (including agents) to contribute:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Translations
|
||||||
|
|
||||||
|
Translations for Hermes Mobile are managed through [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/).
|
||||||
|
|
||||||
|
- Source strings are located in `app/src/main/res/values/strings.xml`.
|
||||||
|
- Localized strings are stored in `app/src/main/res/values-<locale>/strings.xml`.
|
||||||
|
- You can contribute translations directly on Weblate without modifying code or opening manual PRs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
We enforce Kotlin coding conventions and Jetpack Compose best practices.
|
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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -159,6 +159,10 @@ app/src/main/java/com/m57/hermescontrol/
|
|||||||
|
|
||||||
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for our branch workflow, code style guidelines, and PR checklist.
|
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for our branch workflow, code style guidelines, and PR checklist.
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
Help translate Hermes Mobile into your language on [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/)!
|
||||||
|
|
||||||
For developer-specific details, code conventions, and project architecture notes, refer to [AGENTS.md](AGENTS.md).
|
For developer-specific details, code conventions, and project architecture notes, refer to [AGENTS.md](AGENTS.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".HermesControlApp"
|
android:name=".HermesControlApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ data class ServerStoreState(
|
|||||||
// App display language. "system" = follow device locale; otherwise a BCP-47
|
// App display language. "system" = follow device locale; otherwise a BCP-47
|
||||||
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
||||||
val appLanguage: String = "system",
|
val appLanguage: String = "system",
|
||||||
|
// Local on-device hidden profile names (filtered out from list views unless revealed).
|
||||||
|
val hiddenProfiles: List<String> = emptyList(),
|
||||||
// App version the silent update check (issue #867) last completed for.
|
// App version the silent update check (issue #867) last completed for.
|
||||||
// Null / mismatched with BuildConfig.VERSION_NAME → the About tab runs
|
// Null / mismatched with BuildConfig.VERSION_NAME → the About tab runs
|
||||||
// its one-time check again (re-check on app version bump).
|
// its one-time check again (re-check on app version bump).
|
||||||
|
|||||||
@@ -357,6 +357,28 @@ object AuthManager {
|
|||||||
serverStore.update { it.copy(pinnedModels = pinned) }
|
serverStore.update { it.copy(pinnedModels = pinned) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Hidden Profiles (Local On-Device Filtering) ──────────────────────
|
||||||
|
|
||||||
|
fun getHiddenProfiles(): List<String> = serverStore.getLatestState().hiddenProfiles
|
||||||
|
|
||||||
|
fun isProfileHidden(name: String): Boolean = serverStore.getLatestState().hiddenProfiles.contains(name)
|
||||||
|
|
||||||
|
fun hideProfile(name: String) {
|
||||||
|
serverStore.update { s ->
|
||||||
|
s.copy(hiddenProfiles = (s.hiddenProfiles + name).distinct())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unhideProfile(name: String) {
|
||||||
|
serverStore.update { s ->
|
||||||
|
s.copy(hiddenProfiles = s.hiddenProfiles.filter { it != name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHiddenProfiles(hidden: List<String>) {
|
||||||
|
serverStore.update { it.copy(hiddenProfiles = hidden.distinct()) }
|
||||||
|
}
|
||||||
|
|
||||||
fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null)
|
fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null)
|
||||||
|
|
||||||
fun setProfileToken(
|
fun setProfileToken(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -18,6 +19,22 @@ data class McpServer(
|
|||||||
val status: String? = null,
|
val status: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val auth: String? = null,
|
val auth: String? = null,
|
||||||
|
val tools: List<String>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class McpServerTestResponse(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val tools: List<McpServerToolInfo> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class McpServerToolInfo(
|
||||||
|
val name: String,
|
||||||
|
val description: String? = null,
|
||||||
|
@SerialName("schema_chars")
|
||||||
|
val schemaChars: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -13,3 +13,14 @@ data class BuiltinFileSizes(
|
|||||||
val memory: Long? = null,
|
val memory: Long? = null,
|
||||||
val user: Long? = null,
|
val user: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MemoryResetRequest(
|
||||||
|
val target: String = "all",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MemoryResetResponse(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
val deleted: List<String> = emptyList(),
|
||||||
|
)
|
||||||
@@ -239,7 +239,8 @@ data class GroupChatRoomMeta(
|
|||||||
) {
|
) {
|
||||||
val memberNames: List<String>
|
val memberNames: List<String>
|
||||||
get() =
|
get() =
|
||||||
members?.mapNotNull { el ->
|
members
|
||||||
|
?.mapNotNull { el ->
|
||||||
when (el) {
|
when (el) {
|
||||||
is JsonPrimitive -> el.content
|
is JsonPrimitive -> el.content
|
||||||
is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content
|
is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ data class SessionInfo(
|
|||||||
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
|
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
|
||||||
@Serializable(with = LenientNullableBooleanSerializer::class)
|
@Serializable(with = LenientNullableBooleanSerializer::class)
|
||||||
val pinned: Boolean? = null,
|
val pinned: Boolean? = null,
|
||||||
|
// Session-level hidden flag (issue #1019): background agent sessions or internal routines.
|
||||||
|
// Uses LenientNullableBooleanSerializer to tolerate booleans, raw SQLite ints (0/1), and strings.
|
||||||
|
@Serializable(with = LenientNullableBooleanSerializer::class)
|
||||||
|
val hidden: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -95,10 +99,11 @@ data class SessionStatsResponse(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SessionRenameRequest(
|
data class SessionRenameRequest(
|
||||||
// Both fields are optional: the backend PATCH accepts any subset
|
// All fields are optional: the backend PATCH accepts any subset
|
||||||
// (SessionRename model: title/archived/pinned, omitted = unchanged).
|
// (SessionRename model: title/archived/pinned/hidden, omitted = unchanged).
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
val pinned: Boolean? = null,
|
val pinned: Boolean? = null,
|
||||||
|
val hidden: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@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
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
|
|||||||
import com.m57.hermescontrol.data.model.McpCatalogResponse
|
import com.m57.hermescontrol.data.model.McpCatalogResponse
|
||||||
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
||||||
import com.m57.hermescontrol.data.model.McpServer
|
import com.m57.hermescontrol.data.model.McpServer
|
||||||
|
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||||
import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse
|
import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse
|
||||||
@@ -58,6 +59,8 @@ import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest
|
|||||||
import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateResponse
|
import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateResponse
|
||||||
import com.m57.hermescontrol.data.model.MemoryProviderSetupRequest
|
import com.m57.hermescontrol.data.model.MemoryProviderSetupRequest
|
||||||
import com.m57.hermescontrol.data.model.MemoryProviderSetupResponse
|
import com.m57.hermescontrol.data.model.MemoryProviderSetupResponse
|
||||||
|
import com.m57.hermescontrol.data.model.MemoryResetRequest
|
||||||
|
import com.m57.hermescontrol.data.model.MemoryResetResponse
|
||||||
import com.m57.hermescontrol.data.model.MemoryResponse
|
import com.m57.hermescontrol.data.model.MemoryResponse
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
|
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformTestResult
|
import com.m57.hermescontrol.data.model.MessagingPlatformTestResult
|
||||||
@@ -214,6 +217,14 @@ interface HermesApiService {
|
|||||||
@Body body: SessionRenameRequest,
|
@Body body: SessionRenameRequest,
|
||||||
): Response<Unit>
|
): Response<Unit>
|
||||||
|
|
||||||
|
// Hide/unhide rides the same PATCH /api/sessions/{id} — body carries only
|
||||||
|
// {hidden} (backend SessionRename model, any subset accepted; issue #1019).
|
||||||
|
@PATCH("api/sessions/{id}")
|
||||||
|
suspend fun setSessionHidden(
|
||||||
|
@Path("id", encoded = true) sessionId: String,
|
||||||
|
@Body body: SessionRenameRequest,
|
||||||
|
): Response<Unit>
|
||||||
|
|
||||||
@POST("api/sessions/bulk-delete")
|
@POST("api/sessions/bulk-delete")
|
||||||
suspend fun bulkDeleteSessions(
|
suspend fun bulkDeleteSessions(
|
||||||
@Body body: BulkDeleteRequest,
|
@Body body: BulkDeleteRequest,
|
||||||
@@ -509,7 +520,7 @@ interface HermesApiService {
|
|||||||
@POST("api/mcp/servers/{name}/test")
|
@POST("api/mcp/servers/{name}/test")
|
||||||
suspend fun testMcpServer(
|
suspend fun testMcpServer(
|
||||||
@Path("name") name: String,
|
@Path("name") name: String,
|
||||||
): Response<Unit>
|
): Response<McpServerTestResponse>
|
||||||
|
|
||||||
@DELETE("api/mcp/servers/{name}")
|
@DELETE("api/mcp/servers/{name}")
|
||||||
suspend fun deleteMcpServer(
|
suspend fun deleteMcpServer(
|
||||||
@@ -851,8 +862,8 @@ interface HermesApiService {
|
|||||||
|
|
||||||
@POST("api/memory/reset")
|
@POST("api/memory/reset")
|
||||||
suspend fun resetMemory(
|
suspend fun resetMemory(
|
||||||
@Body body: Map<String, String>,
|
@Body body: MemoryResetRequest,
|
||||||
): Response<Map<String, Any>>
|
): Response<MemoryResetResponse>
|
||||||
|
|
||||||
// ── Admin: Credential pool ────────────────────────────────────────
|
// ── Admin: Credential pool ────────────────────────────────────────
|
||||||
@GET("api/credentials/pool")
|
@GET("api/credentials/pool")
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ object ProfileScopeInterceptor : Interceptor {
|
|||||||
"/api/env",
|
"/api/env",
|
||||||
"/api/gateway",
|
"/api/gateway",
|
||||||
"/api/mcp",
|
"/api/mcp",
|
||||||
|
"/api/memory",
|
||||||
"/api/messaging/platforms",
|
"/api/messaging/platforms",
|
||||||
"/api/messaging/telegram/onboarding",
|
"/api/messaging/telegram/onboarding",
|
||||||
"/api/messaging/whatsapp/onboarding",
|
"/api/messaging/whatsapp/onboarding",
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.m57.hermescontrol.ui.chat.extractTodosFromMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
|
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
|
||||||
@@ -31,6 +32,17 @@ object EventParser {
|
|||||||
// ── Notification / event (no id, has method) ─────────────────────
|
// ── Notification / event (no id, has method) ─────────────────────
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val params = response.params?.toAny() as? Map<String, Any?> ?: return WsEvent.Unknown(rawJson)
|
val params = response.params?.toAny() as? Map<String, Any?> ?: return WsEvent.Unknown(rawJson)
|
||||||
|
return parseParams(params, rawJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an event object (bare params map containing `type`, `session_id`, `seq`, `payload`).
|
||||||
|
* Used both for live notifications and for event lists returned by `session.events.since`.
|
||||||
|
*/
|
||||||
|
fun parseParams(
|
||||||
|
params: Map<String, Any?>,
|
||||||
|
rawJson: String = "",
|
||||||
|
): WsEvent {
|
||||||
val eventType = params["type"] as? String ?: return WsEvent.Unknown(rawJson)
|
val eventType = params["type"] as? String ?: return WsEvent.Unknown(rawJson)
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@@ -163,6 +175,13 @@ object EventParser {
|
|||||||
WsEvent.ReviewSummary(text, sessionId)
|
WsEvent.ReviewSummary(text, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"btw.complete" -> {
|
||||||
|
val taskId = payload?.get("task_id") as? String ?: ""
|
||||||
|
val question = payload?.get("question") as? String ?: ""
|
||||||
|
val text = (payload?.get("text") as? String)?.trim() ?: ""
|
||||||
|
WsEvent.BtwComplete(taskId, question, text, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
"session.updated" -> {
|
"session.updated" -> {
|
||||||
WsEvent.SessionUpdated(payload)
|
WsEvent.SessionUpdated(payload)
|
||||||
}
|
}
|
||||||
@@ -171,6 +190,15 @@ object EventParser {
|
|||||||
WsEvent.SessionUsage(payload, sessionId)
|
WsEvent.SessionUsage(payload, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"todo.updated" -> {
|
||||||
|
val revision =
|
||||||
|
(payload?.get("revision") as? Number)?.toInt()
|
||||||
|
?: (params["revision"] as? Number)?.toInt()
|
||||||
|
val rawTodos = payload ?: params
|
||||||
|
val todos = extractTodosFromMap(rawTodos) ?: emptyList()
|
||||||
|
WsEvent.TodoUpdated(todos, revision, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
"reaction" -> {
|
"reaction" -> {
|
||||||
val kind = payload?.get("kind") as? String ?: ""
|
val kind = payload?.get("kind") as? String ?: ""
|
||||||
WsEvent.ReactionEvent(kind)
|
WsEvent.ReactionEvent(kind)
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ import kotlinx.coroutines.flow.filterIsInstance
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
import okio.utf8Size
|
import okio.utf8Size
|
||||||
|
import java.util.Collections
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
@@ -128,6 +130,19 @@ object HermesWsClient {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var outboundDrainJob: Job? = null
|
private var outboundDrainJob: Job? = null
|
||||||
|
|
||||||
|
// ── Sequence tracking & replay on reconnect (issue #1016) ─────────────
|
||||||
|
private val lastSeenSeq = ConcurrentHashMap<String, Int>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var replayEpoch: String? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var replayInFlight: Boolean = false
|
||||||
|
private val replayHold = ConcurrentHashMap<String, MutableList<Pair<Int?, WsEvent>>>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var replayJob: Job? = null
|
||||||
|
|
||||||
// ── Health and Ping/Pong tracking ────────────────────────────────────
|
// ── Health and Ping/Pong tracking ────────────────────────────────────
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -137,6 +152,12 @@ object HermesWsClient {
|
|||||||
val isHealthy: Boolean
|
val isHealthy: Boolean
|
||||||
get() = isConnected && (System.currentTimeMillis() - lastPongTimestamp < 60_000L)
|
get() = isConnected && (System.currentTimeMillis() - lastPongTimestamp < 60_000L)
|
||||||
|
|
||||||
|
// ── Observable latency tracking (issue #1017) ─────────────────────────
|
||||||
|
private val _lastLatencyMs = MutableStateFlow<Long?>(null)
|
||||||
|
|
||||||
|
/** Last measured round-trip latency in milliseconds via [ping], or null when disconnected / unmeasured. */
|
||||||
|
val lastLatencyMs: StateFlow<Long?> = _lastLatencyMs.asStateFlow()
|
||||||
|
|
||||||
private var healthJob: Job? = null
|
private var healthJob: Job? = null
|
||||||
|
|
||||||
private fun startHealthTracking() {
|
private fun startHealthTracking() {
|
||||||
@@ -146,11 +167,37 @@ object HermesWsClient {
|
|||||||
wsScope.launch {
|
wsScope.launch {
|
||||||
while (connected.get()) {
|
while (connected.get()) {
|
||||||
delay(30_000L)
|
delay(30_000L)
|
||||||
|
if (connected.get()) {
|
||||||
|
try {
|
||||||
|
ping(timeoutMs = 10_000L)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Health check ping failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!runHealthCheckPass()) break
|
if (!runHealthCheckPass()) break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ultra-lightweight JSON-RPC liveness check and RTT measurement (issue #1017).
|
||||||
|
* Bypasses agent queues on the backend; measures round-trip time in milliseconds.
|
||||||
|
* Updates [lastLatencyMs] and [lastPongTimestamp].
|
||||||
|
*/
|
||||||
|
suspend fun ping(timeoutMs: Long = 5_000L): Long {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
request(WsMethods.PING, emptyMap(), timeoutMs = timeoutMs).await()
|
||||||
|
val latency = (System.currentTimeMillis() - start).coerceAtLeast(0L)
|
||||||
|
lastPongTimestamp = System.currentTimeMillis()
|
||||||
|
_lastLatencyMs.value = latency
|
||||||
|
return latency
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun setLastLatencyForTest(latency: Long?) {
|
||||||
|
_lastLatencyMs.value = latency
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One liveness pass. Returns false when the tracking loop should stop:
|
* One liveness pass. Returns false when the tracking loop should stop:
|
||||||
* either the connection is already gone or the watchdog fired a cancel.
|
* either the connection is already gone or the watchdog fired a cancel.
|
||||||
@@ -184,6 +231,7 @@ object HermesWsClient {
|
|||||||
private fun stopHealthTracking() {
|
private fun stopHealthTracking() {
|
||||||
healthJob?.cancel()
|
healthJob?.cancel()
|
||||||
healthJob = null
|
healthJob = null
|
||||||
|
_lastLatencyMs.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public observable stream ─────────────────────────────────────────
|
// ── Public observable stream ─────────────────────────────────────────
|
||||||
@@ -256,12 +304,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 -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,6 +325,20 @@ object HermesWsClient {
|
|||||||
.filterIsInstance<WsEvent.ChangeEvent>()
|
.filterIsInstance<WsEvent.ChangeEvent>()
|
||||||
.collect { ChangeEventHub.emit(it) }
|
.collect { ChangeEventHub.emit(it) }
|
||||||
}
|
}
|
||||||
|
// Track gateway replay_epoch across gateway.ready events (issue #1016)
|
||||||
|
wsScope.launch {
|
||||||
|
events.collect { event ->
|
||||||
|
if (event is WsEvent.GatewayReady) {
|
||||||
|
val epoch = event.data?.get("replay_epoch") as? String
|
||||||
|
if (!epoch.isNullOrEmpty()) {
|
||||||
|
if (replayEpoch != null && replayEpoch != epoch) {
|
||||||
|
lastSeenSeq.clear()
|
||||||
|
}
|
||||||
|
replayEpoch = epoch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Connection helpers ────────────────────────────────────────────────
|
// ── Connection helpers ────────────────────────────────────────────────
|
||||||
@@ -573,6 +638,12 @@ object HermesWsClient {
|
|||||||
queuedMessagesById.clear()
|
queuedMessagesById.clear()
|
||||||
pendingPromptSubmits.clear()
|
pendingPromptSubmits.clear()
|
||||||
pendingReply = false
|
pendingReply = false
|
||||||
|
lastSeenSeq.clear()
|
||||||
|
replayHold.clear()
|
||||||
|
replayEpoch = null
|
||||||
|
replayJob?.cancel()
|
||||||
|
replayJob = null
|
||||||
|
replayInFlight = false
|
||||||
}
|
}
|
||||||
connected.set(false)
|
connected.set(false)
|
||||||
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
@@ -816,6 +887,135 @@ object HermesWsClient {
|
|||||||
onSent = onSent,
|
onSent = onSent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Sequence tracking & replay on reconnect (issue #1016) ─────────────
|
||||||
|
|
||||||
|
private fun triggerReplay() {
|
||||||
|
if (lastSeenSeq.isEmpty() || replayInFlight) return
|
||||||
|
replayJob?.cancel()
|
||||||
|
replayJob =
|
||||||
|
wsScope.launch {
|
||||||
|
fetchReplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchReplay() {
|
||||||
|
if (replayInFlight || lastSeenSeq.isEmpty()) return
|
||||||
|
replayInFlight = true
|
||||||
|
val sessionsToReplay = lastSeenSeq.keys().toList()
|
||||||
|
for (sid in sessionsToReplay) {
|
||||||
|
replayHold[sid] = Collections.synchronizedList(mutableListOf())
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (sid in sessionsToReplay) {
|
||||||
|
val lastSeen = lastSeenSeq[sid] ?: continue
|
||||||
|
try {
|
||||||
|
val deferred =
|
||||||
|
request(
|
||||||
|
method = WsMethods.SESSION_EVENTS_SINCE,
|
||||||
|
params = mapOf("session_id" to sid, "last_seen" to lastSeen, "since_seq" to lastSeen),
|
||||||
|
timeoutMs = 10_000L,
|
||||||
|
)
|
||||||
|
val result = deferred.await()
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val resultMap =
|
||||||
|
when (result) {
|
||||||
|
is JsonElement -> result.toAny() as? Map<String, Any?>
|
||||||
|
is Map<*, *> -> result as? Map<String, Any?>
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (resultMap != null) {
|
||||||
|
val epoch = resultMap["epoch"] as? String
|
||||||
|
if (!epoch.isNullOrEmpty()) {
|
||||||
|
if (replayEpoch != null && replayEpoch != epoch) {
|
||||||
|
replayEpoch = epoch
|
||||||
|
lastSeenSeq.clear()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
replayEpoch = epoch
|
||||||
|
}
|
||||||
|
val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance<Map<String, Any?>>()
|
||||||
|
if (eventsList != null) {
|
||||||
|
for (eventMap in eventsList) {
|
||||||
|
val eventSeq = (eventMap["seq"] as? Number)?.toInt()
|
||||||
|
val eventSid = (eventMap["session_id"] as? String) ?: sid
|
||||||
|
if (eventSeq != null && eventSid.isNotEmpty()) {
|
||||||
|
val prev = lastSeenSeq[eventSid] ?: 0
|
||||||
|
if (eventSeq <= prev) continue
|
||||||
|
lastSeenSeq[eventSid] = eventSeq
|
||||||
|
}
|
||||||
|
val parsedEvent = EventParser.parseParams(eventMap)
|
||||||
|
val finalEvent =
|
||||||
|
if (parsedEvent is WsEvent.MessageComplete) {
|
||||||
|
parsedEvent.copy(
|
||||||
|
storedSessionId =
|
||||||
|
ActiveSessionHolder.resolveStoredSessionId(
|
||||||
|
parsedEvent.sessionId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parsedEvent
|
||||||
|
}
|
||||||
|
parsedEvents.tryEmit(finalEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Replay failed for session $sid: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (sid in sessionsToReplay) {
|
||||||
|
val held = replayHold.remove(sid)
|
||||||
|
if (held != null) {
|
||||||
|
synchronized(held) {
|
||||||
|
for ((heldSeq, heldEvent) in held) {
|
||||||
|
if (heldSeq != null) {
|
||||||
|
val prev = lastSeenSeq[sid] ?: 0
|
||||||
|
if (heldSeq <= prev) continue
|
||||||
|
lastSeenSeq[sid] = heldSeq
|
||||||
|
}
|
||||||
|
parsedEvents.tryEmit(heldEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replayInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun getSeqWatermarks(): Map<String, Int> = HashMap(lastSeenSeq)
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun setSeqWatermark(
|
||||||
|
sessionId: String,
|
||||||
|
seq: Int,
|
||||||
|
) {
|
||||||
|
lastSeenSeq[sessionId] = seq
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun clearSeqWatermarks() {
|
||||||
|
lastSeenSeq.clear()
|
||||||
|
replayHold.clear()
|
||||||
|
replayEpoch = null
|
||||||
|
replayInFlight = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun isReplayInFlight(): Boolean = replayInFlight
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun setReplayEpochForTest(epoch: String?) {
|
||||||
|
replayEpoch = epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal suspend fun fetchReplayForTest() {
|
||||||
|
fetchReplay()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal ─────────────────────────────────────────────────────────
|
// ── Internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun openSocket() {
|
private fun openSocket() {
|
||||||
@@ -934,6 +1134,7 @@ object HermesWsClient {
|
|||||||
messageQueue.poll()
|
messageQueue.poll()
|
||||||
markQueuedMessageSent(msg)
|
markQueuedMessageSent(msg)
|
||||||
}
|
}
|
||||||
|
triggerReplay()
|
||||||
}
|
}
|
||||||
disconnectIfIdleInBackground()
|
disconnectIfIdleInBackground()
|
||||||
}
|
}
|
||||||
@@ -960,6 +1161,39 @@ object HermesWsClient {
|
|||||||
removeQueuedMessage(rpcId)
|
removeQueuedMessage(rpcId)
|
||||||
resolvePending(rpcId, rpc.result, null)
|
resolvePending(rpcId, rpc.result, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rpc.id == null) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val params = rpc.params?.toAny() as? Map<String, Any?>
|
||||||
|
val sid =
|
||||||
|
(params?.get("session_id") as? String)
|
||||||
|
?: ((params?.get("payload") as? Map<*, *>)?.get("session_id") as? String)
|
||||||
|
val seq = (params?.get("seq") as? Number)?.toInt()
|
||||||
|
|
||||||
|
if (!sid.isNullOrBlank() && seq != null) {
|
||||||
|
val prev = lastSeenSeq[sid] ?: 0
|
||||||
|
if (seq <= prev) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val parsed = EventParser.parse(rpc, text)
|
||||||
|
val finalEvent =
|
||||||
|
if (parsed is WsEvent.MessageComplete) {
|
||||||
|
parsed.copy(
|
||||||
|
storedSessionId = ActiveSessionHolder.resolveStoredSessionId(parsed.sessionId),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parsed
|
||||||
|
}
|
||||||
|
if (replayInFlight && replayHold.containsKey(sid)) {
|
||||||
|
replayHold[sid]?.add(seq to finalEvent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastSeenSeq[sid] = seq
|
||||||
|
parsedEvents.tryEmit(finalEvent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val parsed = EventParser.parse(rpc, text)
|
val parsed = EventParser.parse(rpc, text)
|
||||||
if (parsed is WsEvent.MessageComplete) {
|
if (parsed is WsEvent.MessageComplete) {
|
||||||
parsed.copy(
|
parsed.copy(
|
||||||
@@ -992,7 +1226,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? =
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||||
|
|
||||||
/** Parsed WebSocket events emitted by [HermesWsClient]. */
|
/** Parsed WebSocket events emitted by [HermesWsClient]. */
|
||||||
sealed class WsEvent {
|
sealed class WsEvent {
|
||||||
// ── Gateway lifecycle ────────────────────────────────────────────────
|
// ── Gateway lifecycle ────────────────────────────────────────────────
|
||||||
@@ -150,6 +152,18 @@ sealed class WsEvent {
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context-aware side-question completion event (issue #1015).
|
||||||
|
* Emitted when `prompt.btw` completes answering a side question.
|
||||||
|
* Payload: `{ task_id: "...", question: "...", text: "..." }`
|
||||||
|
*/
|
||||||
|
data class BtwComplete(
|
||||||
|
val taskId: String = "",
|
||||||
|
val question: String = "",
|
||||||
|
val text: String = "",
|
||||||
|
val sessionId: String? = null,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Status ───────────────────────────────────────────────────────────
|
// ── Status ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class StatusUpdate(
|
data class StatusUpdate(
|
||||||
@@ -171,6 +185,17 @@ sealed class WsEvent {
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revisioned todo snapshot event emitted by backend todo tool updates (issue #1018).
|
||||||
|
* Event: `todo.updated`
|
||||||
|
* Payload: `{ "todos": [...], "revision": Int }`
|
||||||
|
*/
|
||||||
|
data class TodoUpdated(
|
||||||
|
val todos: List<TodoItem> = emptyList(),
|
||||||
|
val revision: Int? = null,
|
||||||
|
val sessionId: String? = null,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
// ── RPC responses ────────────────────────────────────────────────────
|
// ── RPC responses ────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class RpcResult(
|
data class RpcResult(
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ object WsMethods {
|
|||||||
const val SESSION_TITLE = "session.title"
|
const val SESSION_TITLE = "session.title"
|
||||||
const val SESSION_BRANCH = "session.branch"
|
const val SESSION_BRANCH = "session.branch"
|
||||||
|
|
||||||
|
/** Replay recorded events newer than client's last-seen seq for a session. */
|
||||||
|
const val SESSION_EVENTS_SINCE = "session.events.since"
|
||||||
|
|
||||||
|
/** Replay buffer telemetry for ops/debug. */
|
||||||
|
const val SESSION_EVENTS_STATS = "session.events.stats"
|
||||||
|
|
||||||
/** Live context-window occupancy for the chat meter (desktop-mirror). */
|
/** Live context-window occupancy for the chat meter (desktop-mirror). */
|
||||||
const val SESSION_CONTEXT_BREAKDOWN = "session.context_breakdown"
|
const val SESSION_CONTEXT_BREAKDOWN = "session.context_breakdown"
|
||||||
|
|
||||||
@@ -25,11 +31,17 @@ object WsMethods {
|
|||||||
|
|
||||||
// ── Interaction ───────────────────────────────────────────────────────
|
// ── Interaction ───────────────────────────────────────────────────────
|
||||||
const val PROMPT_SUBMIT = "prompt.submit"
|
const val PROMPT_SUBMIT = "prompt.submit"
|
||||||
|
const val PROMPT_BTW = "prompt.btw"
|
||||||
const val CLARIFY_RESPOND = "clarify.respond"
|
const val CLARIFY_RESPOND = "clarify.respond"
|
||||||
const val APPROVAL_RESPOND = "approval.respond"
|
const val APPROVAL_RESPOND = "approval.respond"
|
||||||
const val SUDO_RESPOND = "sudo.respond"
|
const val SUDO_RESPOND = "sudo.respond"
|
||||||
const val SECRET_RESPOND = "secret.respond"
|
const val SECRET_RESPOND = "secret.respond"
|
||||||
|
|
||||||
|
// ── Heartbeat & Latency (issue #1017) ──────────────────────────────────
|
||||||
|
|
||||||
|
/** Ultra-lightweight liveness check and round-trip latency measurement. */
|
||||||
|
const val PING = "ping"
|
||||||
|
|
||||||
// ── Commands catalog ──────────────────────────────────────────────────
|
// ── Commands catalog ──────────────────────────────────────────────────
|
||||||
const val COMMANDS_CATALOG = "commands.catalog"
|
const val COMMANDS_CATALOG = "commands.catalog"
|
||||||
const val COMMAND_DISPATCH = "command.dispatch"
|
const val COMMAND_DISPATCH = "command.dispatch"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.content.Intent
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.RemoteInput
|
import androidx.core.app.RemoteInput
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
@@ -124,11 +125,20 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resumeSession(storedSessionId: String): String {
|
private suspend fun resumeSession(storedSessionId: String): String {
|
||||||
|
val profile = AuthManager.activeProfileId.value
|
||||||
|
val params =
|
||||||
|
mutableMapOf<String, Any>(
|
||||||
|
"session_id" to storedSessionId,
|
||||||
|
"omit_messages" to true,
|
||||||
|
)
|
||||||
|
if (!profile.isNullOrBlank()) {
|
||||||
|
params["profile"] = profile
|
||||||
|
}
|
||||||
val result =
|
val result =
|
||||||
HermesWsClient
|
HermesWsClient
|
||||||
.request(
|
.request(
|
||||||
WsMethods.SESSION_RESUME,
|
WsMethods.SESSION_RESUME,
|
||||||
mapOf("session_id" to storedSessionId, "omit_messages" to true),
|
params,
|
||||||
timeoutMs = REPLY_TIMEOUT_MS,
|
timeoutMs = REPLY_TIMEOUT_MS,
|
||||||
).await() as? Map<*, *>
|
).await() as? Map<*, *>
|
||||||
val runtimeSessionId =
|
val runtimeSessionId =
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.bots
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
||||||
import com.m57.hermescontrol.data.model.BotRosterMeta
|
import com.m57.hermescontrol.data.model.BotRosterMeta
|
||||||
import com.m57.hermescontrol.data.model.CreateProfileRequest
|
import com.m57.hermescontrol.data.model.CreateProfileRequest
|
||||||
@@ -47,12 +48,13 @@ data class BotsUiState(
|
|||||||
val activeProfileName: String? = null,
|
val activeProfileName: String? = null,
|
||||||
val searchQuery: String = "",
|
val searchQuery: String = "",
|
||||||
val showHidden: Boolean = false,
|
val showHidden: Boolean = false,
|
||||||
|
val hiddenProfiles: Set<String> = emptySet(),
|
||||||
val selectedTab: BotsTab = BotsTab.BOTS,
|
val selectedTab: BotsTab = BotsTab.BOTS,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val toastMessage: String? = null,
|
val toastMessage: String? = null,
|
||||||
) {
|
) {
|
||||||
val hasHiddenBots: Boolean
|
val hasHiddenBots: Boolean
|
||||||
get() = profiles.any { it.isHidden }
|
get() = profiles.any { it.isHidden || it.name in hiddenProfiles }
|
||||||
|
|
||||||
val allGroups: List<GroupInfo>
|
val allGroups: List<GroupInfo>
|
||||||
get() {
|
get() {
|
||||||
@@ -60,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>()
|
||||||
|
|
||||||
@@ -99,7 +106,8 @@ data class BotsUiState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return groupNameMap.values.map { gName ->
|
return groupNameMap.values
|
||||||
|
.map { gName ->
|
||||||
val lower = gName.lowercase()
|
val lower = gName.lowercase()
|
||||||
val matchingRoom =
|
val matchingRoom =
|
||||||
snapshotRooms[gName]
|
snapshotRooms[gName]
|
||||||
@@ -110,7 +118,8 @@ data class BotsUiState(
|
|||||||
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
|
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
|
||||||
|
|
||||||
val matchedProfiles =
|
val matchedProfiles =
|
||||||
profiles.filter { profile ->
|
profiles
|
||||||
|
.filter { profile ->
|
||||||
val botGroups = profile.botMeta()?.allGroups.orEmpty()
|
val botGroups = profile.botMeta()?.allGroups.orEmpty()
|
||||||
botGroups.any { it.equals(gName, ignoreCase = true) } ||
|
botGroups.any { it.equals(gName, ignoreCase = true) } ||
|
||||||
roomMemberNames.contains(profile.name.lowercase()) ||
|
roomMemberNames.contains(profile.name.lowercase()) ||
|
||||||
@@ -185,20 +194,19 @@ data class BotsUiState(
|
|||||||
val query = searchQuery.trim().lowercase()
|
val query = searchQuery.trim().lowercase()
|
||||||
return profiles
|
return profiles
|
||||||
.filter { profile ->
|
.filter { profile ->
|
||||||
if (!showHidden && profile.isHidden) return@filter false
|
val isHidden = profile.isHidden || profile.name in hiddenProfiles
|
||||||
|
if (!showHidden && isHidden) return@filter false
|
||||||
if (query.isBlank()) return@filter true
|
if (query.isBlank()) return@filter true
|
||||||
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 },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,6 +220,7 @@ class BotsViewModel(
|
|||||||
val uiState: StateFlow<BotsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<BotsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
_uiState.update { it.copy(hiddenProfiles = AuthManager.getHiddenProfiles().toSet()) }
|
||||||
if (autoLoad) {
|
if (autoLoad) {
|
||||||
loadBots()
|
loadBots()
|
||||||
}
|
}
|
||||||
@@ -262,6 +271,7 @@ class BotsViewModel(
|
|||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
profiles = profilesWithMeta,
|
profiles = profilesWithMeta,
|
||||||
activeProfileName = activeName ?: it.activeProfileName,
|
activeProfileName = activeName ?: it.activeProfileName,
|
||||||
|
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -273,6 +283,7 @@ class BotsViewModel(
|
|||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
profiles = profilesResult.data.profiles.orEmpty(),
|
profiles = profilesResult.data.profiles.orEmpty(),
|
||||||
activeProfileName = activeName ?: it.activeProfileName,
|
activeProfileName = activeName ?: it.activeProfileName,
|
||||||
|
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -458,7 +469,8 @@ class BotsViewModel(
|
|||||||
rooms = updatedRooms,
|
rooms = updatedRooms,
|
||||||
deleted = updatedDeleted,
|
deleted = updatedDeleted,
|
||||||
)
|
)
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
|
.request(
|
||||||
WsMethods.PROFILES_CONFIGURE,
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
mapOf(
|
mapOf(
|
||||||
"name" to defaultProfile.name,
|
"name" to defaultProfile.name,
|
||||||
@@ -522,7 +534,8 @@ class BotsViewModel(
|
|||||||
rooms = updatedRooms,
|
rooms = updatedRooms,
|
||||||
deleted = deletedMap,
|
deleted = deletedMap,
|
||||||
)
|
)
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
|
.request(
|
||||||
WsMethods.PROFILES_CONFIGURE,
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
mapOf(
|
mapOf(
|
||||||
"name" to defaultProfile.name,
|
"name" to defaultProfile.name,
|
||||||
|
|||||||
@@ -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,7 +303,8 @@ 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
|
||||||
|
.filter {
|
||||||
val botGroups = it.botMeta()?.allGroups.orEmpty()
|
val botGroups = it.botMeta()?.allGroups.orEmpty()
|
||||||
botGroups.any { g -> g.equals(groupName, ignoreCase = true) } ||
|
botGroups.any { g -> g.equals(groupName, ignoreCase = true) } ||
|
||||||
roomMemberNames.contains(it.name.lowercase()) ||
|
roomMemberNames.contains(it.name.lowercase()) ||
|
||||||
@@ -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,7 +470,8 @@ class GroupChatViewModel(
|
|||||||
|
|
||||||
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
|
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
|
||||||
|
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
|
.request(
|
||||||
WsMethods.PROFILES_CONFIGURE,
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
mapOf(
|
mapOf(
|
||||||
"name" to defaultProfile.name,
|
"name" to defaultProfile.name,
|
||||||
@@ -559,7 +565,8 @@ 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
|
||||||
|
.request(
|
||||||
WsMethods.PROFILES_CONFIGURE,
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
mapOf(
|
mapOf(
|
||||||
"name" to defaultProfile.name,
|
"name" to defaultProfile.name,
|
||||||
@@ -629,7 +636,8 @@ class GroupChatViewModel(
|
|||||||
storedId?.let { inFlightTurns[it] = turnDeferred }
|
storedId?.let { inFlightTurns[it] = turnDeferred }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
|
.request(
|
||||||
WsMethods.PROMPT_SUBMIT,
|
WsMethods.PROMPT_SUBMIT,
|
||||||
mapOf("session_id" to runtimeId, "text" to prompt),
|
mapOf("session_id" to runtimeId, "text" to prompt),
|
||||||
).await()
|
).await()
|
||||||
@@ -662,7 +670,8 @@ class GroupChatViewModel(
|
|||||||
storedId?.let { inFlightTurns[it] = turnDeferred }
|
storedId?.let { inFlightTurns[it] = turnDeferred }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
|
.request(
|
||||||
WsMethods.PROMPT_SUBMIT,
|
WsMethods.PROMPT_SUBMIT,
|
||||||
mapOf("session_id" to runtimeId, "text" to prompt),
|
mapOf("session_id" to runtimeId, "text" to prompt),
|
||||||
).await()
|
).await()
|
||||||
@@ -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,7 +1070,8 @@ class GroupChatViewModel(
|
|||||||
|
|
||||||
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
|
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
|
||||||
|
|
||||||
HermesWsClient.request(
|
HermesWsClient
|
||||||
|
.request(
|
||||||
WsMethods.PROFILES_CONFIGURE,
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
mapOf(
|
mapOf(
|
||||||
"name" to defaultProfile.name,
|
"name" to defaultProfile.name,
|
||||||
|
|||||||
@@ -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,16 +180,28 @@ 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 {
|
||||||
if (it.savingAttachmentPath == path) {
|
if (it.savingAttachmentPath == path) {
|
||||||
|
|||||||
@@ -85,11 +85,13 @@ data class TodoItem(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
val status: String = "pending", // "pending" | "in_progress" | "completed" | "cancelled"
|
val status: String = "pending", // "pending" | "in_progress" | "completed" | "cancelled"
|
||||||
|
val parent: String? = null,
|
||||||
) {
|
) {
|
||||||
val isCompleted: Boolean get() = status == "completed" || status == "done"
|
val isCompleted: Boolean get() = status == "completed" || status == "done"
|
||||||
val isInProgress: Boolean get() = status == "in_progress" || status == "running"
|
val isInProgress: Boolean get() = status == "in_progress" || status == "running"
|
||||||
val isCancelled: Boolean get() = status == "cancelled" || status == "failed"
|
val isCancelled: Boolean get() = status == "cancelled" || status == "failed"
|
||||||
val isPending: Boolean get() = status == "pending" || status == "queued"
|
val isPending: Boolean get() = status == "pending" || status == "queued"
|
||||||
|
val isSubtask: Boolean get() = !parent.isNullOrBlank()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,8 +114,10 @@ data class SubagentIndicator(
|
|||||||
) {
|
) {
|
||||||
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
|
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
|
||||||
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
|
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
|
||||||
|
val isCancelled: Boolean get() = status == "cancelled" || status == "stopped" || status == "canceled"
|
||||||
|
val isSteered: Boolean get() = status == "steered"
|
||||||
val isQueued: Boolean get() = status == "queued"
|
val isQueued: Boolean get() = status == "queued"
|
||||||
val isRunning: Boolean get() = !isComplete && !isFailed && !isQueued
|
val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class MessageRole {
|
enum class MessageRole {
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package com.m57.hermescontrol.ui.chat
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import android.speech.RecognizerIntent
|
import android.speech.RecognizerIntent
|
||||||
import android.speech.SpeechRecognizer
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -100,6 +100,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
|||||||
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||||
@@ -230,7 +231,7 @@ fun ChatScreen(
|
|||||||
.collectLatest { firstVisibleIndex ->
|
.collectLatest { firstVisibleIndex ->
|
||||||
if (firstVisibleIndex > 2) {
|
if (firstVisibleIndex > 2) {
|
||||||
isOlderPagingArmed = true
|
isOlderPagingArmed = true
|
||||||
} else if (isOlderPagingArmed) {
|
} else if (isOlderPagingArmed || state.messages.size <= 3) {
|
||||||
val anchorId = state.messages.getOrNull(firstVisibleIndex)?.id
|
val anchorId = state.messages.getOrNull(firstVisibleIndex)?.id
|
||||||
val offset = scrollController.captureAnchorOffset()
|
val offset = scrollController.captureAnchorOffset()
|
||||||
pagingAnchor.value = if (anchorId != null) anchorId to offset else null
|
pagingAnchor.value = if (anchorId != null) anchorId to offset else null
|
||||||
@@ -342,21 +343,18 @@ fun ChatScreen(
|
|||||||
) { granted ->
|
) { granted ->
|
||||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
if (granted) {
|
if (granted) {
|
||||||
if (SpeechRecognizer.isRecognitionAvailable(context)) {
|
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
||||||
val intent =
|
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
||||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
|
||||||
putExtra(
|
|
||||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
|
||||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
|
|
||||||
)
|
|
||||||
putExtra(
|
|
||||||
RecognizerIntent.EXTRA_PROMPT,
|
|
||||||
micListeningPrompt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
isListening = true
|
isListening = true
|
||||||
launchExternalActivity {
|
launchExternalActivity {
|
||||||
|
try {
|
||||||
speechLauncher.launch(intent)
|
speechLauncher.launch(intent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
isListening = false
|
||||||
|
scrollScope.launch {
|
||||||
|
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scrollScope.launch {
|
scrollScope.launch {
|
||||||
@@ -611,7 +609,8 @@ fun ChatScreen(
|
|||||||
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
|
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
|
||||||
onOpenSettings = {
|
onOpenSettings = {
|
||||||
val intent =
|
val intent =
|
||||||
android.content.Intent(
|
android.content
|
||||||
|
.Intent(
|
||||||
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
android.net.Uri.parse("package:${context.packageName}"),
|
android.net.Uri.parse("package:${context.packageName}"),
|
||||||
).apply {
|
).apply {
|
||||||
@@ -779,21 +778,18 @@ fun ChatScreen(
|
|||||||
Manifest.permission.RECORD_AUDIO,
|
Manifest.permission.RECORD_AUDIO,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
if (SpeechRecognizer.isRecognitionAvailable(context)) {
|
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
||||||
val intent =
|
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
||||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
|
||||||
putExtra(
|
|
||||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
|
||||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
|
|
||||||
)
|
|
||||||
putExtra(
|
|
||||||
RecognizerIntent.EXTRA_PROMPT,
|
|
||||||
micListeningPrompt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
isListening = true
|
isListening = true
|
||||||
launchExternalActivity {
|
launchExternalActivity {
|
||||||
|
try {
|
||||||
speechLauncher.launch(intent)
|
speechLauncher.launch(intent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
isListening = false
|
||||||
|
scrollScope.launch {
|
||||||
|
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scrollScope.launch {
|
scrollScope.launch {
|
||||||
@@ -928,10 +924,19 @@ fun ChatScreen(
|
|||||||
SubagentInspectionSheet(
|
SubagentInspectionSheet(
|
||||||
indicators = state.subagentIndicators,
|
indicators = state.subagentIndicators,
|
||||||
todos = state.todos,
|
todos = state.todos,
|
||||||
|
onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) },
|
||||||
|
onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) },
|
||||||
onDismiss = { showSubagentInspectionSheet = false },
|
onDismiss = { showSubagentInspectionSheet = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.btwState?.let { btw ->
|
||||||
|
SideQuestionSheet(
|
||||||
|
state = btw,
|
||||||
|
onDismiss = { viewModel.dismissBtw() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
viewingImage?.let { image ->
|
viewingImage?.let { image ->
|
||||||
ImageViewerDialog(
|
ImageViewerDialog(
|
||||||
image = image,
|
image = image,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -346,6 +353,8 @@ data class ChatUiState(
|
|||||||
val reactionKind: String? = null,
|
val reactionKind: String? = null,
|
||||||
/** Monotonic trigger ID so consecutive same-kind reactions re-animate. */
|
/** Monotonic trigger ID so consecutive same-kind reactions re-animate. */
|
||||||
val reactionTriggerId: Long = 0L,
|
val reactionTriggerId: Long = 0L,
|
||||||
|
/** Side-question state for /btw (issue #1015). */
|
||||||
|
val btwState: BtwUiState? = null,
|
||||||
/** Subagent delegation indicators (issue #538) — transient UI state. */
|
/** Subagent delegation indicators (issue #538) — transient UI state. */
|
||||||
val subagentIndicators: List<SubagentIndicator> = emptyList(),
|
val subagentIndicators: List<SubagentIndicator> = emptyList(),
|
||||||
/** Agent todo / plan items (issue #736). */
|
/** Agent todo / plan items (issue #736). */
|
||||||
@@ -372,6 +381,17 @@ data class ClarifyUi(
|
|||||||
val clarifyId: String? = null,
|
val clarifyId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the context-aware side-question bottom sheet (issue #1015, `/btw`).
|
||||||
|
*/
|
||||||
|
data class BtwUiState(
|
||||||
|
val taskId: String? = null,
|
||||||
|
val question: String = "",
|
||||||
|
val answer: String? = null,
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* String sent to the agent when a clarify prompt is dismissed (the Dismiss
|
* String sent to the agent when a clarify prompt is dismissed (the Dismiss
|
||||||
* button). This is a *reject* — "I'm not answering this question" — NOT an
|
* button). This is a *reject* — "I'm not answering this question" — NOT an
|
||||||
@@ -627,6 +647,8 @@ class ChatViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
ProfileSwitchCoordinator.switched
|
ProfileSwitchCoordinator.switched
|
||||||
.collect { _ ->
|
.collect { _ ->
|
||||||
|
pendingGoneSessionNotice = false
|
||||||
|
sessionHasServerPresence = false
|
||||||
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,6 +659,8 @@ class ChatViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
ProfileSwitchCoordinator.connectionSwitched
|
ProfileSwitchCoordinator.connectionSwitched
|
||||||
.collect { _ ->
|
.collect { _ ->
|
||||||
|
pendingGoneSessionNotice = false
|
||||||
|
sessionHasServerPresence = false
|
||||||
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -764,7 +788,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
|
||||||
@@ -1537,6 +1563,12 @@ class ChatViewModel(
|
|||||||
// it matches the server echo and the transcript sync dedupes instead
|
// it matches the server echo and the transcript sync dedupes instead
|
||||||
// of rendering a duplicate below its answer.
|
// of rendering a duplicate below its answer.
|
||||||
val result = slashDispatcher.dispatch(command)
|
val result = slashDispatcher.dispatch(command)
|
||||||
|
|
||||||
|
if (result is SlashResult.SideQuestion) {
|
||||||
|
handleSideQuestionCommand(result.question)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val displayContent =
|
val displayContent =
|
||||||
if (result is SlashResult.QueuePrompt) result.displayContent else command
|
if (result is SlashResult.QueuePrompt) result.displayContent else command
|
||||||
val userMsg = ChatMessage(role = MessageRole.USER, content = displayContent)
|
val userMsg = ChatMessage(role = MessageRole.USER, content = displayContent)
|
||||||
@@ -1618,6 +1650,10 @@ class ChatViewModel(
|
|||||||
handleQueueCommand(command)
|
handleQueueCommand(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is SlashResult.SideQuestion -> {
|
||||||
|
handleSideQuestionCommand(result.question)
|
||||||
|
}
|
||||||
|
|
||||||
is SlashResult.RpcDispatch -> {
|
is SlashResult.RpcDispatch -> {
|
||||||
dispatchViaRpc(command)
|
dispatchViaRpc(command)
|
||||||
}
|
}
|
||||||
@@ -1642,6 +1678,75 @@ class ChatViewModel(
|
|||||||
submitPrompt(arg, queued = true)
|
submitPrompt(arg, queued = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Side Questions via /btw (issue #1015) ─────────────────────────────
|
||||||
|
|
||||||
|
fun dismissBtw() {
|
||||||
|
_uiState.update { it.copy(btwState = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitSideQuestion(question: String) {
|
||||||
|
val trimmed = question.trim()
|
||||||
|
if (trimmed.isBlank()) {
|
||||||
|
addAssistantMessage(
|
||||||
|
"Usage: `/btw <question>` — Ask a side question about this session without mutating its history.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId
|
||||||
|
if (sessionId.isNullOrBlank()) {
|
||||||
|
addAssistantMessage("No active session for side questions. Start a chat first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
btwState =
|
||||||
|
BtwUiState(
|
||||||
|
question = trimmed,
|
||||||
|
isLoading = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val rpcResult =
|
||||||
|
wsClient
|
||||||
|
.request(
|
||||||
|
WsMethods.PROMPT_BTW,
|
||||||
|
mapOf("session_id" to sessionId, "text" to trimmed),
|
||||||
|
).await()
|
||||||
|
val taskId = (rpcResult as? Map<*, *>)?.get("task_id") as? String
|
||||||
|
if (!taskId.isNullOrBlank()) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.btwState?.let { current ->
|
||||||
|
state.copy(btwState = current.copy(taskId = taskId))
|
||||||
|
} ?: state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.btwState?.let { current ->
|
||||||
|
state.copy(
|
||||||
|
btwState =
|
||||||
|
current.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Failed to dispatch side question",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} ?: state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSideQuestionCommand(question: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
slashUsageStore.recordUse("btw")
|
||||||
|
}
|
||||||
|
submitSideQuestion(question)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Update from chat (issue #862) ────────────────────────────────────
|
// ── Update from chat (issue #862) ────────────────────────────────────
|
||||||
// `/update` can't travel via the slash worker (the backend handler is
|
// `/update` can't travel via the slash worker (the backend handler is
|
||||||
// interactive + session-exiting → guaranteed 45s timeout). Intercept it
|
// interactive + session-exiting → guaranteed 45s timeout). Intercept it
|
||||||
@@ -1859,6 +1964,85 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send course-correction guidance to a live subagent child session (issue #1030).
|
||||||
|
*/
|
||||||
|
fun steerSubagent(
|
||||||
|
indicator: SubagentIndicator,
|
||||||
|
message: String,
|
||||||
|
) {
|
||||||
|
val trimmed = message.trim()
|
||||||
|
if (trimmed.isBlank()) return
|
||||||
|
val sessionId = runtimeSessionId ?: return
|
||||||
|
val subagentId = indicator.subagentId
|
||||||
|
val steerCommand =
|
||||||
|
if (!subagentId.isNullOrBlank()) {
|
||||||
|
"/steer $subagentId $trimmed"
|
||||||
|
} else {
|
||||||
|
"/steer $trimmed"
|
||||||
|
}
|
||||||
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
wsClient.sendRedirect(
|
||||||
|
sessionId,
|
||||||
|
steerCommand,
|
||||||
|
onSent = { id -> trackRequest(id, WsMethods.SESSION_REDIRECT) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_uiState.update { current ->
|
||||||
|
val updated =
|
||||||
|
current.subagentIndicators.map { ind ->
|
||||||
|
if (ind.subagentId == indicator.subagentId &&
|
||||||
|
(ind.goal == indicator.goal || indicator.subagentId != null)
|
||||||
|
) {
|
||||||
|
val newLogs =
|
||||||
|
(ind.logs + SubagentLogLine(text = "Course correction: \"$trimmed\"", isSummary = true))
|
||||||
|
.takeLast(30)
|
||||||
|
ind.copy(status = "steered", logs = newLogs)
|
||||||
|
} else {
|
||||||
|
ind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.copy(subagentIndicators = updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop / interrupt a single live subagent early (issue #1030).
|
||||||
|
*/
|
||||||
|
fun stopSubagent(indicator: SubagentIndicator) {
|
||||||
|
val sessionId = runtimeSessionId ?: return
|
||||||
|
val subagentId = indicator.subagentId
|
||||||
|
val stopCommand =
|
||||||
|
if (!subagentId.isNullOrBlank()) {
|
||||||
|
"/stop $subagentId"
|
||||||
|
} else {
|
||||||
|
"/stop"
|
||||||
|
}
|
||||||
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
wsClient.sendRedirect(
|
||||||
|
sessionId,
|
||||||
|
stopCommand,
|
||||||
|
onSent = { id -> trackRequest(id, WsMethods.SESSION_REDIRECT) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_uiState.update { current ->
|
||||||
|
val updated =
|
||||||
|
current.subagentIndicators.map { ind ->
|
||||||
|
if (ind.subagentId == indicator.subagentId &&
|
||||||
|
(ind.goal == indicator.goal || indicator.subagentId != null)
|
||||||
|
) {
|
||||||
|
val newLogs =
|
||||||
|
(ind.logs + SubagentLogLine(text = "Stopped subagent", isError = true))
|
||||||
|
.takeLast(30)
|
||||||
|
ind.copy(status = "cancelled", logs = newLogs)
|
||||||
|
} else {
|
||||||
|
ind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.copy(subagentIndicators = updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun createNewSession(setLoading: Boolean = true) {
|
fun createNewSession(setLoading: Boolean = true) {
|
||||||
// A fresh create has no persisted row until the first prompt.
|
// A fresh create has no persisted row until the first prompt.
|
||||||
sessionHasServerPresence = false
|
sessionHasServerPresence = false
|
||||||
@@ -1901,7 +2085,10 @@ class ChatViewModel(
|
|||||||
// Fetch available bot profiles for @ autocomplete
|
// Fetch available bot profiles for @ autocomplete
|
||||||
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() }
|
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() }
|
||||||
if (profilesResult is NetworkResult.Success) {
|
if (profilesResult is NetworkResult.Success) {
|
||||||
val profiles = profilesResult.data.profiles.orEmpty()
|
val profiles =
|
||||||
|
profilesResult.data.profiles.orEmpty().filter {
|
||||||
|
!AuthManager.isProfileHidden(it.name) && it.botMeta()?.hidden != true
|
||||||
|
}
|
||||||
_uiState.update { it.copy(availableBots = profiles) }
|
_uiState.update { it.copy(availableBots = profiles) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2165,7 +2352,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
|
||||||
@@ -2283,6 +2473,27 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
|
val errorMsg = result.error.message
|
||||||
|
val is404 =
|
||||||
|
errorMsg.contains("404", ignoreCase = true) ||
|
||||||
|
errorMsg.contains("not found", ignoreCase = true)
|
||||||
|
if (is404) {
|
||||||
|
// A 404 from GET /api/sessions/{id}/messages indicates the session has no
|
||||||
|
// persisted rows in state.db (e.g. newly created/lazy unprompted session, or empty history).
|
||||||
|
// It is NOT a fatal error: keep the session open with empty transcript, clear loading,
|
||||||
|
// and mark hydrated cleanly.
|
||||||
|
_uiState.update {
|
||||||
|
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isLoadingOlder = false,
|
||||||
|
hasOlderMessages = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
hydratedGeneration = generation
|
||||||
|
finishResumeWhenHydrated(generation)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
if (hydratedGeneration == generation) hydratedGeneration = -1L
|
if (hydratedGeneration == generation) hydratedGeneration = -1L
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it
|
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it
|
||||||
@@ -2390,10 +2601,19 @@ class ChatViewModel(
|
|||||||
) {
|
) {
|
||||||
val requestSequence = ++resumeRequestSequence
|
val requestSequence = ++resumeRequestSequence
|
||||||
activeResumeRequestSequence = requestSequence
|
activeResumeRequestSequence = requestSequence
|
||||||
|
val profile = AuthManager.activeProfileId.value
|
||||||
|
val params =
|
||||||
|
mutableMapOf<String, Any>(
|
||||||
|
"session_id" to sessionId,
|
||||||
|
"omit_messages" to true,
|
||||||
|
)
|
||||||
|
if (!profile.isNullOrBlank()) {
|
||||||
|
params["profile"] = profile
|
||||||
|
}
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
wsClient.send(
|
wsClient.send(
|
||||||
WsMethods.SESSION_RESUME,
|
WsMethods.SESSION_RESUME,
|
||||||
mapOf("session_id" to sessionId, "omit_messages" to true),
|
params,
|
||||||
onSent = { id ->
|
onSent = { id ->
|
||||||
trackSessionRequest(
|
trackSessionRequest(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -2467,6 +2687,17 @@ class ChatViewModel(
|
|||||||
private fun recoverGoneSession(sessionId: String) {
|
private fun recoverGoneSession(sessionId: String) {
|
||||||
if (_uiState.value.currentSessionId != sessionId) return
|
if (_uiState.value.currentSessionId != sessionId) return
|
||||||
if (sessionGoneRecoveryInFlight) return
|
if (sessionGoneRecoveryInFlight) return
|
||||||
|
if (_uiState.value.messages.isNotEmpty()) {
|
||||||
|
cancelResumeRetry()
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isResumeRetrying = false,
|
||||||
|
resumeError = "Session not found on server (displaying cached messages)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
sessionGoneRecoveryInFlight = true
|
sessionGoneRecoveryInFlight = true
|
||||||
cancelResumeRetry()
|
cancelResumeRetry()
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -73,6 +71,7 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.ToolGenerating -> event.sessionId
|
is WsEvent.ToolGenerating -> event.sessionId
|
||||||
is WsEvent.SubagentEvent -> event.sessionId
|
is WsEvent.SubagentEvent -> event.sessionId
|
||||||
is WsEvent.ReviewSummary -> event.sessionId
|
is WsEvent.ReviewSummary -> event.sessionId
|
||||||
|
is WsEvent.BtwComplete -> event.sessionId
|
||||||
is WsEvent.SessionUsage -> event.sessionId
|
is WsEvent.SessionUsage -> event.sessionId
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
@@ -131,8 +130,12 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
|
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
|
||||||
|
|
||||||
|
is WsEvent.BtwComplete -> onBtwComplete(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.SessionUsage -> onSessionUsage(state, streamingState, event)
|
is WsEvent.SessionUsage -> onSessionUsage(state, streamingState, event)
|
||||||
|
|
||||||
|
is WsEvent.TodoUpdated -> onTodoUpdated(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.GatewayError -> onGatewayError(state, streamingState, event)
|
is WsEvent.GatewayError -> onGatewayError(state, streamingState, event)
|
||||||
@@ -654,6 +657,36 @@ object ChatWsEventReducer {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── BtwComplete (Side question completed) ─────────────────────────────
|
||||||
|
|
||||||
|
private fun onBtwComplete(
|
||||||
|
state: ChatUiState,
|
||||||
|
streamingState: StreamingState,
|
||||||
|
event: WsEvent.BtwComplete,
|
||||||
|
): ReducerResult {
|
||||||
|
val currentBtw = state.btwState
|
||||||
|
val updatedBtw =
|
||||||
|
if (currentBtw != null) {
|
||||||
|
currentBtw.copy(
|
||||||
|
taskId = event.taskId.ifBlank { currentBtw.taskId },
|
||||||
|
question = event.question.ifBlank { currentBtw.question },
|
||||||
|
answer = event.text,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BtwUiState(
|
||||||
|
taskId = event.taskId,
|
||||||
|
question = event.question,
|
||||||
|
answer = event.text,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ReducerResult(
|
||||||
|
state = state.copy(btwState = updatedBtw),
|
||||||
|
streamingState = streamingState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── RpcError ──────────────────────────────────────────────────────
|
// ── RpcError ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun onRpcError(
|
private fun onRpcError(
|
||||||
@@ -839,6 +872,20 @@ object ChatWsEventReducer {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onTodoUpdated(
|
||||||
|
state: ChatUiState,
|
||||||
|
streamingState: StreamingState,
|
||||||
|
event: WsEvent.TodoUpdated,
|
||||||
|
): ReducerResult {
|
||||||
|
if (event.sessionId != null && state.currentSessionId != null && event.sessionId != state.currentSessionId) {
|
||||||
|
return ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
}
|
||||||
|
return ReducerResult(
|
||||||
|
state = state.copy(todos = event.todos),
|
||||||
|
streamingState = streamingState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun onSessionUsage(
|
private fun onSessionUsage(
|
||||||
state: ChatUiState,
|
state: ChatUiState,
|
||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
@@ -926,7 +973,8 @@ fun extractTodosFromMap(data: Map<String, Any?>?): List<TodoItem>? {
|
|||||||
val id = (map["id"] as? String) ?: continue
|
val id = (map["id"] as? String) ?: continue
|
||||||
val content = (map["content"] as? String) ?: (map["text"] as? String) ?: ""
|
val content = (map["content"] as? String) ?: (map["text"] as? String) ?: ""
|
||||||
val status = (map["status"] as? String) ?: "pending"
|
val status = (map["status"] as? String) ?: "pending"
|
||||||
items.add(TodoItem(id = id, content = content, status = status))
|
val parent = (map["parent"] as? String)?.takeIf { it.isNotBlank() }
|
||||||
|
items.add(TodoItem(id = id, content = content, status = status, parent = parent))
|
||||||
}
|
}
|
||||||
return items.takeIf { it.isNotEmpty() }
|
return items.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
@@ -943,14 +991,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
|
||||||
|
.mapNotNull { item ->
|
||||||
val itemObj = item as? JsonObject ?: return@mapNotNull null
|
val itemObj = item as? JsonObject ?: return@mapNotNull null
|
||||||
val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null
|
val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null
|
||||||
val contentStr =
|
val contentStr =
|
||||||
(itemObj["content"] as? JsonPrimitive)?.content
|
(itemObj["content"] as? JsonPrimitive)?.content
|
||||||
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
|
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
|
||||||
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
|
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
|
||||||
TodoItem(id = id, content = contentStr, status = status)
|
val parent = (itemObj["parent"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
|
||||||
|
TodoItem(id = id, content = contentStr, status = status, parent = parent)
|
||||||
}.takeIf { it.isNotEmpty() }
|
}.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,19 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
"/btw" -> {
|
||||||
|
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
|
||||||
|
SlashResult.SideQuestion(question = arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
SlashResult.RpcDispatch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,5 +131,16 @@ 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()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context-aware side question about the current session (issue #1015).
|
||||||
|
* Dispatched via the `prompt.btw` RPC. Answers without mutating the
|
||||||
|
* session's history or invalidating prompt caching.
|
||||||
|
*/
|
||||||
|
data class SideQuestion(
|
||||||
|
val question: String,
|
||||||
|
) : SlashResult()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.speech.RecognizerIntent
|
||||||
|
import android.speech.SpeechRecognizer
|
||||||
|
|
||||||
|
object SpeechInputHelper {
|
||||||
|
/**
|
||||||
|
* Checks if speech recognition is available on this device through any registered provider,
|
||||||
|
* including standard Android [SpeechRecognizer] services or offline/third-party apps
|
||||||
|
* that handle [RecognizerIntent.ACTION_RECOGNIZE_SPEECH] (e.g. FUTO Voice Input).
|
||||||
|
*/
|
||||||
|
fun isSpeechInputAvailable(
|
||||||
|
context: Context,
|
||||||
|
isRecognizerServiceAvailable: (Context) -> Boolean = { ctx ->
|
||||||
|
runCatching { SpeechRecognizer.isRecognitionAvailable(ctx) }.getOrDefault(false)
|
||||||
|
},
|
||||||
|
): Boolean {
|
||||||
|
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
|
||||||
|
val activities =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
runCatching {
|
||||||
|
context.packageManager.queryIntentActivities(
|
||||||
|
intent,
|
||||||
|
PackageManager.ResolveInfoFlags.of(0L),
|
||||||
|
)
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
runCatching {
|
||||||
|
context.packageManager.queryIntentActivities(intent, 0)
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
return activities.isNotEmpty() || isRecognizerServiceAvailable(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the standard speech recognition intent with language model and prompt.
|
||||||
|
*/
|
||||||
|
fun createSpeechIntent(prompt: String): Intent =
|
||||||
|
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||||
|
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
|
||||||
|
)
|
||||||
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_PROMPT,
|
||||||
|
prompt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,8 +136,8 @@ fun ChatInputBar(
|
|||||||
// also enforced at dispatch time (issue #576, deliverable #3).
|
// also enforced at dispatch time (issue #576, deliverable #3).
|
||||||
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
|
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
|
||||||
val commandNames =
|
val commandNames =
|
||||||
commandCatalog.pairs
|
(commandCatalog.pairs.map { it[0] } + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop"))
|
||||||
.map { it[0] }
|
.distinct()
|
||||||
.filter { it.lowercase() !in hiddenSlashDisplay }
|
.filter { it.lowercase() !in hiddenSlashDisplay }
|
||||||
|
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.ErrorOutline
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.m57.hermescontrol.ui.chat.BtwUiState
|
||||||
|
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet displaying a context-aware side question and its answer (issue #1015, `/btw`).
|
||||||
|
* Answers from a conversation snapshot without mutating the chat transcript.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SideQuestionSheet(
|
||||||
|
state: BtwUiState,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
modifier = modifier.testTag("side_question_sheet"),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Side Question",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Question Banner
|
||||||
|
if (state.question.isNotBlank()) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Question",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = state.question,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (state.isLoading) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Answering from conversation snapshot...",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (!state.error.isNullOrBlank()) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ErrorOutline,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = state.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer state
|
||||||
|
val answer = state.answer
|
||||||
|
if (!answer.isNullOrBlank()) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
border =
|
||||||
|
androidx.compose.foundation.BorderStroke(
|
||||||
|
1.dp,
|
||||||
|
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(14.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Answer",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val clipboard =
|
||||||
|
context.getSystemService(
|
||||||
|
Context.CLIPBOARD_SERVICE,
|
||||||
|
) as? ClipboardManager
|
||||||
|
clipboard?.setPrimaryClip(ClipData.newPlainText("Side Question Answer", answer))
|
||||||
|
Toast.makeText(context, "Copied answer", Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = "Copy answer",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
MarkdownText(
|
||||||
|
text = answer,
|
||||||
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+437
-30
@@ -1,6 +1,8 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.components
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -11,25 +13,42 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.AltRoute
|
||||||
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.Autorenew
|
import androidx.compose.material.icons.filled.Autorenew
|
||||||
import androidx.compose.material.icons.filled.Cancel
|
import androidx.compose.material.icons.filled.Cancel
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Groups
|
import androidx.compose.material.icons.filled.Groups
|
||||||
|
import androidx.compose.material.icons.filled.HourglassEmpty
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -37,6 +56,7 @@ import androidx.compose.ui.platform.testTag
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -46,17 +66,22 @@ import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
|||||||
import com.m57.hermescontrol.ui.chat.TodoItem
|
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspection sheet displaying active & completed subagent tasks and agent todos.
|
* Inspection sheet displaying active & completed subagent tasks and agent todos (issue #1030).
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SubagentInspectionSheet(
|
fun SubagentInspectionSheet(
|
||||||
indicators: List<SubagentIndicator> = emptyList(),
|
indicators: List<SubagentIndicator> = emptyList(),
|
||||||
todos: List<TodoItem> = emptyList(),
|
todos: List<TodoItem> = emptyList(),
|
||||||
|
onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null,
|
||||||
|
onStopSubagent: ((SubagentIndicator) -> Unit)? = null,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val todoTree = remember(todos) { buildTodoTree(todos) }
|
||||||
|
var collapsedParentIds by remember { mutableStateOf(setOf<String>()) }
|
||||||
|
val displayRows = remember(todoTree, collapsedParentIds) { flattenTodoTree(todoTree, collapsedParentIds) }
|
||||||
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -111,9 +136,9 @@ fun SubagentInspectionSheet(
|
|||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
if (todos.isNotEmpty()) {
|
if (displayRows.isNotEmpty()) {
|
||||||
item(key = "todos_header") {
|
item(key = "todos_header") {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -134,11 +159,26 @@ fun SubagentInspectionSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemsIndexed(
|
items(
|
||||||
items = todos,
|
items = displayRows,
|
||||||
key = { index, todo -> "todo-${todo.id}_$index" },
|
key = { row -> "todo-${row.todo.id}_${row.depth}" },
|
||||||
) { _, todo ->
|
) { row ->
|
||||||
TodoInspectionCard(todo = todo)
|
TodoInspectionCard(
|
||||||
|
row = row,
|
||||||
|
onToggleExpand =
|
||||||
|
if (row.hasChildren) {
|
||||||
|
{
|
||||||
|
collapsedParentIds =
|
||||||
|
if (row.todo.id in collapsedParentIds) {
|
||||||
|
collapsedParentIds - row.todo.id
|
||||||
|
} else {
|
||||||
|
collapsedParentIds + row.todo.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +211,21 @@ fun SubagentInspectionSheet(
|
|||||||
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
|
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
|
||||||
},
|
},
|
||||||
) { _, indicator ->
|
) { _, indicator ->
|
||||||
InspectionItemCard(indicator = indicator)
|
InspectionItemCard(
|
||||||
|
indicator = indicator,
|
||||||
|
onSteer =
|
||||||
|
if (onSteerSubagent != null) {
|
||||||
|
{ message -> onSteerSubagent(indicator, message) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onStop =
|
||||||
|
if (onStopSubagent != null) {
|
||||||
|
{ onStopSubagent(indicator) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,12 +234,128 @@ fun SubagentInspectionSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node in the hierarchical todo tree.
|
||||||
|
*/
|
||||||
|
data class TodoNode(
|
||||||
|
val item: TodoItem,
|
||||||
|
val children: List<TodoNode> = emptyList(),
|
||||||
|
val depth: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattened row representation ready for lazy column rendering with indentation and collapse state.
|
||||||
|
*/
|
||||||
|
data class TodoDisplayRow(
|
||||||
|
val todo: TodoItem,
|
||||||
|
val depth: Int,
|
||||||
|
val hasChildren: Boolean,
|
||||||
|
val isExpanded: Boolean,
|
||||||
|
val completedSubtasksCount: Int,
|
||||||
|
val totalSubtasksCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a hierarchical tree from a flat list of [TodoItem]s using the [TodoItem.parent] property.
|
||||||
|
*/
|
||||||
|
fun buildTodoTree(todos: List<TodoItem>): List<TodoNode> {
|
||||||
|
val itemsById = todos.associateBy { it.id }
|
||||||
|
val childrenByParent = mutableMapOf<String, MutableList<TodoItem>>()
|
||||||
|
val rootItems = mutableListOf<TodoItem>()
|
||||||
|
|
||||||
|
for (item in todos) {
|
||||||
|
val p = item.parent
|
||||||
|
if (p != null && itemsById.containsKey(p) && p != item.id) {
|
||||||
|
childrenByParent.getOrPut(p) { mutableListOf() }.add(item)
|
||||||
|
} else {
|
||||||
|
rootItems.add(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildNode(
|
||||||
|
item: TodoItem,
|
||||||
|
depth: Int,
|
||||||
|
): TodoNode {
|
||||||
|
val children = childrenByParent[item.id]?.map { buildNode(it, depth + 1) } ?: emptyList()
|
||||||
|
return TodoNode(item = item, children = children, depth = depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootItems.map { buildNode(it, 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllDescendantTodos(node: TodoNode): List<TodoItem> {
|
||||||
|
val descendants = mutableListOf<TodoItem>()
|
||||||
|
for (child in node.children) {
|
||||||
|
descendants.add(child.item)
|
||||||
|
descendants.addAll(getAllDescendantTodos(child))
|
||||||
|
}
|
||||||
|
return descendants
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flattenTodoTree(
|
||||||
|
nodes: List<TodoNode>,
|
||||||
|
collapsedParentIds: Set<String>,
|
||||||
|
): List<TodoDisplayRow> {
|
||||||
|
val result = mutableListOf<TodoDisplayRow>()
|
||||||
|
|
||||||
|
fun traverse(node: TodoNode) {
|
||||||
|
val isExpanded = node.item.id !in collapsedParentIds
|
||||||
|
val descendants = getAllDescendantTodos(node)
|
||||||
|
val completedCount = descendants.count { it.isCompleted }
|
||||||
|
val totalCount = descendants.size
|
||||||
|
|
||||||
|
result.add(
|
||||||
|
TodoDisplayRow(
|
||||||
|
todo = node.item,
|
||||||
|
depth = node.depth,
|
||||||
|
hasChildren = node.children.isNotEmpty(),
|
||||||
|
isExpanded = isExpanded,
|
||||||
|
completedSubtasksCount = completedCount,
|
||||||
|
totalSubtasksCount = totalCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (node.children.isNotEmpty() && isExpanded) {
|
||||||
|
for (child in node.children) {
|
||||||
|
traverse(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (root in nodes) {
|
||||||
|
traverse(root)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TodoInspectionCard(todo: TodoItem) {
|
private fun TodoInspectionCard(
|
||||||
|
row: TodoDisplayRow,
|
||||||
|
onToggleExpand: (() -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val todo = row.todo
|
||||||
|
val depth = row.depth
|
||||||
|
val startPadding = (depth * 20).coerceAtMost(60).dp
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = startPadding)
|
||||||
|
.then(
|
||||||
|
if (onToggleExpand != null) {
|
||||||
|
Modifier.clickable(onClick = onToggleExpand)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color =
|
||||||
|
if (depth > 0) {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(12.dp),
|
||||||
@@ -194,29 +364,52 @@ 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)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth > 0) {
|
||||||
|
Text(
|
||||||
|
text = "↳",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
|
modifier = Modifier.padding(end = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = statusIcon,
|
imageVector = statusIcon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = statusTint,
|
tint = statusTint,
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(if (depth > 0) 16.dp else 18.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
Text(
|
Text(
|
||||||
text = todo.content,
|
text = todo.content,
|
||||||
style =
|
style =
|
||||||
|
if (depth > 0) {
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
MaterialTheme.typography.bodyMedium.copy(
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
|
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
|
||||||
),
|
)
|
||||||
|
},
|
||||||
fontWeight = if (todo.isInProgress) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (todo.isInProgress) FontWeight.Bold else FontWeight.Normal,
|
||||||
color =
|
color =
|
||||||
if (todo.isCompleted) {
|
if (todo.isCompleted) {
|
||||||
@@ -226,29 +419,104 @@ private fun TodoInspectionCard(todo: TodoItem) {
|
|||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (row.hasChildren) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${row.completedSubtasksCount}/${row.totalSubtasksCount}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = if (row.isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||||
|
contentDescription = if (row.isExpanded) "Collapse" else "Expand",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InspectionItemCard(indicator: SubagentIndicator) {
|
private fun InspectionItemCard(
|
||||||
|
indicator: SubagentIndicator,
|
||||||
|
onSteer: ((String) -> Unit)? = null,
|
||||||
|
onStop: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
var showSteerInput by remember { mutableStateOf(false) }
|
||||||
|
var steerText by remember { mutableStateOf("") }
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
|
||||||
|
val (statusIcon, statusTint, statusLabel) =
|
||||||
|
when {
|
||||||
|
indicator.isComplete -> {
|
||||||
|
Triple(
|
||||||
|
Icons.Filled.CheckCircle,
|
||||||
|
statusColors.success,
|
||||||
|
stringResource(R.string.subagent_status_completed),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
indicator.isFailed -> {
|
||||||
|
Triple(
|
||||||
|
Icons.Filled.Cancel,
|
||||||
|
statusColors.error,
|
||||||
|
stringResource(R.string.subagent_status_failed),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
indicator.isCancelled -> {
|
||||||
|
Triple(
|
||||||
|
Icons.Filled.Cancel,
|
||||||
|
statusColors.warning,
|
||||||
|
stringResource(R.string.subagent_status_cancelled),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
indicator.isSteered -> {
|
||||||
|
Triple(
|
||||||
|
Icons.AutoMirrored.Filled.AltRoute,
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
stringResource(R.string.subagent_status_steered),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
indicator.isQueued -> {
|
||||||
|
Triple(
|
||||||
|
Icons.Filled.HourglassEmpty,
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
stringResource(R.string.subagent_status_running),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Triple(
|
||||||
|
Icons.Filled.Autorenew,
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
stringResource(R.string.subagent_status_running),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
// Header Row: Status Icon, Goal, and Status Badge
|
||||||
val statusColors = LocalHermesStatusColors.current
|
Row(
|
||||||
val (statusIcon, statusTint) =
|
modifier = Modifier.fillMaxWidth(),
|
||||||
when {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
indicator.isComplete ->
|
) {
|
||||||
Icons.Filled.CheckCircle to statusColors.success
|
|
||||||
indicator.isFailed ->
|
|
||||||
Icons.Filled.Cancel to statusColors.error
|
|
||||||
else ->
|
|
||||||
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
|
|
||||||
}
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = statusIcon,
|
imageVector = statusIcon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -264,6 +532,44 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
|
|||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = statusTint.copy(alpha = 0.15f),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = statusLabel,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = statusTint,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration & Model info
|
||||||
|
if (indicator.durationSeconds != null || !indicator.model.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (indicator.durationSeconds != null) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.subagent_duration, indicator.durationSeconds),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!indicator.model.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = indicator.model,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!indicator.summary.isNullOrBlank()) {
|
if (!indicator.summary.isNullOrBlank()) {
|
||||||
@@ -275,6 +581,7 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live Transcript Logs
|
||||||
if (indicator.logs.isNotEmpty()) {
|
if (indicator.logs.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Column(
|
Column(
|
||||||
@@ -311,6 +618,106 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interactive Controls for active subagent (issue #1030)
|
||||||
|
if (indicator.isRunning || indicator.isSteered) {
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (onSteer != null) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { showSteerInput = !showSteerInput },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.AltRoute,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(stringResource(R.string.subagent_action_steer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onStop != null) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onStop,
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = statusColors.error,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Stop,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.subagent_action_stop))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline Steering Input Field
|
||||||
|
AnimatedVisibility(visible = showSteerInput) {
|
||||||
|
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = steerText,
|
||||||
|
onValueChange = { steerText = it },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.subagent_steer_placeholder),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onSend = {
|
||||||
|
if (steerText.isNotBlank()) {
|
||||||
|
onSteer?.invoke(steerText)
|
||||||
|
steerText = ""
|
||||||
|
showSteerInput = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (steerText.isNotBlank()) {
|
||||||
|
onSteer?.invoke(steerText)
|
||||||
|
steerText = ""
|
||||||
|
showSteerInput = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = steerText.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = stringResource(R.string.subagent_action_steer),
|
||||||
|
tint =
|
||||||
|
if (steerText.isNotBlank()) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,8 +951,13 @@ object ToolViewBuilder {
|
|||||||
titleForTool(toolName)
|
titleForTool(toolName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"memory" -> "Saving"
|
|
||||||
else -> titleForTool(toolName)
|
"memory" -> {
|
||||||
|
"Saving"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
titleForTool(toolName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,12 +965,24 @@ object ToolViewBuilder {
|
|||||||
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,25 +1011,33 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 =
|
||||||
@@ -1631,6 +1769,7 @@ object ToolViewBuilder {
|
|||||||
val id = firstString(item, listOf("id"))
|
val id = firstString(item, listOf("id"))
|
||||||
val content = firstString(item, listOf("content"))
|
val content = firstString(item, listOf("content"))
|
||||||
val status = firstString(item, listOf("status")).ifEmpty { "pending" }
|
val status = firstString(item, listOf("status")).ifEmpty { "pending" }
|
||||||
|
val parent = firstString(item, listOf("parent"))
|
||||||
val marker =
|
val marker =
|
||||||
when (status) {
|
when (status) {
|
||||||
"completed" -> "[x]"
|
"completed" -> "[x]"
|
||||||
@@ -1638,9 +1777,9 @@ object ToolViewBuilder {
|
|||||||
"cancelled" -> "[~]"
|
"cancelled" -> "[~]"
|
||||||
else -> "[ ]"
|
else -> "[ ]"
|
||||||
}
|
}
|
||||||
"$marker $id. $content"
|
val indent = if (parent.isNotEmpty()) " ↳ " else ""
|
||||||
}
|
"$indent$marker $id. $content"
|
||||||
.joinToString("\n")
|
}.joinToString("\n")
|
||||||
.takeIf { it.isNotEmpty() }
|
.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1669,8 +1808,7 @@ object ToolViewBuilder {
|
|||||||
} else {
|
} else {
|
||||||
"#$fid $content"
|
"#$fid $content"
|
||||||
}
|
}
|
||||||
}
|
}.joinToString("\n")
|
||||||
.joinToString("\n")
|
|
||||||
.takeIf { it.isNotEmpty() }
|
.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1700,8 +1838,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 +1864,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 +1892,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 +1917,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 +1936,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 +1986,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,7 +2006,8 @@ object ToolViewBuilder {
|
|||||||
val body =
|
val body =
|
||||||
buildString {
|
buildString {
|
||||||
val textPart =
|
val textPart =
|
||||||
multimodalText.ifEmpty {
|
multimodalText
|
||||||
|
.ifEmpty {
|
||||||
firstString(
|
firstString(
|
||||||
result,
|
result,
|
||||||
listOf("text_summary"),
|
listOf("text_summary"),
|
||||||
|
|||||||
@@ -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,7 +16,8 @@ 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
|
||||||
|
.Builder(
|
||||||
name = "Neurology",
|
name = "Neurology",
|
||||||
defaultWidth = 24.dp,
|
defaultWidth = 24.dp,
|
||||||
defaultHeight = 24.dp,
|
defaultHeight = 24.dp,
|
||||||
|
|||||||
@@ -82,23 +82,28 @@ 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(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.AddMcpServerRequest
|
||||||
|
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||||
|
import com.m57.hermescontrol.data.model.McpServerToolInfo
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side parser for MCP server JSON configs (Claude Desktop / Cursor formats, single server JSON).
|
||||||
|
* Issue #1029.
|
||||||
|
*/
|
||||||
|
object McpJsonParser {
|
||||||
|
private val json =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses pasted JSON text into a list of [AddMcpServerRequest] models.
|
||||||
|
* Returns an empty list if no valid server entries are found or if parsing fails.
|
||||||
|
*/
|
||||||
|
fun parse(text: String): List<AddMcpServerRequest> {
|
||||||
|
val trimmed = text.trim()
|
||||||
|
if (trimmed.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val rootElement: JsonElement =
|
||||||
|
try {
|
||||||
|
json.parseToJsonElement(trimmed)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rootElement !is JsonObject) return emptyList()
|
||||||
|
|
||||||
|
// 1. Check for wrapped "mcpServers" or "mcp_servers" container
|
||||||
|
val wrapper = rootElement["mcpServers"] ?: rootElement["mcp_servers"]
|
||||||
|
if (wrapper is JsonObject) {
|
||||||
|
return parseServerMap(wrapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if the root object itself is a single server definition (has "command" or "url" directly)
|
||||||
|
if (isServerObject(rootElement)) {
|
||||||
|
val name =
|
||||||
|
rootElement["name"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||||
|
?: inferName(rootElement)
|
||||||
|
val req = parseSingleServer(name, rootElement)
|
||||||
|
return if (req != null) listOf(req) else emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check if the root object is a dictionary of name -> serverObject
|
||||||
|
val mapEntries = parseServerMap(rootElement)
|
||||||
|
if (mapEntries.isNotEmpty()) {
|
||||||
|
return mapEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseServerMap(obj: JsonObject): List<AddMcpServerRequest> {
|
||||||
|
val results = mutableListOf<AddMcpServerRequest>()
|
||||||
|
for ((name, element) in obj) {
|
||||||
|
if (element is JsonObject && isServerObject(element)) {
|
||||||
|
val req = parseSingleServer(name, element)
|
||||||
|
if (req != null) {
|
||||||
|
results.add(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isServerObject(obj: JsonObject): Boolean =
|
||||||
|
obj.containsKey("command") || obj.containsKey("url") || obj.containsKey("transport")
|
||||||
|
|
||||||
|
private fun parseSingleServer(
|
||||||
|
name: String,
|
||||||
|
obj: JsonObject,
|
||||||
|
): AddMcpServerRequest? {
|
||||||
|
val cleanName = name.trim()
|
||||||
|
if (cleanName.isEmpty()) return null
|
||||||
|
|
||||||
|
val url =
|
||||||
|
obj["url"]
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
val command =
|
||||||
|
obj["command"]
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
if (url == null && command == null) return null
|
||||||
|
|
||||||
|
val args: List<String>? =
|
||||||
|
when (val argsElement = obj["args"]) {
|
||||||
|
is JsonArray -> {
|
||||||
|
argsElement.mapNotNull {
|
||||||
|
(it as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val env: Map<String, String>? =
|
||||||
|
when (val envElement = obj["env"]) {
|
||||||
|
is JsonObject -> {
|
||||||
|
envElement.entries
|
||||||
|
.mapNotNull { (k, v) ->
|
||||||
|
(v as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.let { k to it }
|
||||||
|
}.toMap()
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth detection from "auth" or "headers"
|
||||||
|
var auth = (obj["auth"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.lowercase()
|
||||||
|
var bearerToken = (obj["bearerToken"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
|
||||||
|
|
||||||
|
val headers = obj["headers"]
|
||||||
|
if (headers is JsonObject) {
|
||||||
|
val authHeader =
|
||||||
|
(headers["Authorization"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
|
||||||
|
?: (headers["authorization"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
|
||||||
|
if (authHeader != null) {
|
||||||
|
auth = "header"
|
||||||
|
if (authHeader.startsWith("Bearer ", ignoreCase = true)) {
|
||||||
|
bearerToken = authHeader.substring(7).trim()
|
||||||
|
} else {
|
||||||
|
bearerToken = authHeader.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalAuth =
|
||||||
|
when (auth) {
|
||||||
|
"oauth" -> "oauth"
|
||||||
|
"header" -> "header"
|
||||||
|
else -> "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
return AddMcpServerRequest(
|
||||||
|
name = cleanName,
|
||||||
|
url = if (command == null) url else null,
|
||||||
|
command = command,
|
||||||
|
args = args,
|
||||||
|
env = env,
|
||||||
|
auth = finalAuth,
|
||||||
|
bearerToken = if (finalAuth == "header") bearerToken else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inferName(obj: JsonObject): String {
|
||||||
|
val command = (obj["command"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.trim()
|
||||||
|
val args = obj["args"]
|
||||||
|
if (!command.isNullOrEmpty()) {
|
||||||
|
if (args is JsonArray && args.isNotEmpty()) {
|
||||||
|
val firstArg = (args[0] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
|
||||||
|
if (firstArg != null && !firstArg.startsWith("-")) {
|
||||||
|
return sanitizeName(firstArg.split("/").last())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sanitizeName(command.split("/").last())
|
||||||
|
}
|
||||||
|
val url = (obj["url"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.trim()
|
||||||
|
if (!url.isNullOrEmpty()) {
|
||||||
|
return try {
|
||||||
|
val host = java.net.URI(url).host ?: "server"
|
||||||
|
host.split(".").firstOrNull { it != "api" && it != "www" && it != "mcp" } ?: "server"
|
||||||
|
} catch (_: Exception) {
|
||||||
|
"server"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "server"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeName(raw: String): String =
|
||||||
|
raw
|
||||||
|
.replace(Regex("""\.(cjs|js|mjs|py|ts)$""", RegexOption.IGNORE_CASE), "")
|
||||||
|
.replace(Regex("""^(mcp-server-|server-|mcp-)"""), "")
|
||||||
|
.replace(Regex("""(-mcp-server|-mcp|-server)$"""), "")
|
||||||
|
.ifEmpty { "server" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token overhead estimator for MCP server tool schemas (issue #1029).
|
||||||
|
*/
|
||||||
|
object McpTokenEstimator {
|
||||||
|
/**
|
||||||
|
* Approximate token overhead for a server's tool schemas.
|
||||||
|
* Sum of ceil(schema_chars / 4.0) over tools carrying valid schema_chars.
|
||||||
|
*/
|
||||||
|
fun estimateTokens(tools: List<McpServerToolInfo>): Int? {
|
||||||
|
var total = 0
|
||||||
|
var sawValid = false
|
||||||
|
for (tool in tools) {
|
||||||
|
val chars = tool.schemaChars
|
||||||
|
if (chars != null && chars > 0) {
|
||||||
|
total += ceil(chars / 4.0).toInt()
|
||||||
|
sawValid = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (sawValid) total else null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatTokenOverhead(
|
||||||
|
toolCount: Int,
|
||||||
|
tokenEstimate: Int?,
|
||||||
|
): String {
|
||||||
|
if (tokenEstimate == null) {
|
||||||
|
return if (toolCount == 1) "1 tool" else "$toolCount tools"
|
||||||
|
}
|
||||||
|
val tokenFormatted =
|
||||||
|
if (tokenEstimate >= 1000) {
|
||||||
|
String.format(Locale.US, "%.1fk", tokenEstimate / 1000.0)
|
||||||
|
} else {
|
||||||
|
tokenEstimate.toString()
|
||||||
|
}
|
||||||
|
val toolStr = if (toolCount == 1) "1 tool" else "$toolCount tools"
|
||||||
|
return "$toolStr • ~$tokenFormatted tokens"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health status for visual color-coded badges (issue #1029).
|
||||||
|
*/
|
||||||
|
enum class McpHealthStatus {
|
||||||
|
HEALTHY,
|
||||||
|
AUTH_REQUIRED,
|
||||||
|
ERROR,
|
||||||
|
TESTING,
|
||||||
|
UNKNOWN,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun resolve(
|
||||||
|
isTesting: Boolean,
|
||||||
|
testResult: McpServerTestResponse?,
|
||||||
|
serverStatus: String?,
|
||||||
|
serverError: String?,
|
||||||
|
): McpHealthStatus {
|
||||||
|
if (isTesting) return TESTING
|
||||||
|
if (testResult != null) {
|
||||||
|
if (testResult.ok) return HEALTHY
|
||||||
|
val err = (testResult.error ?: "").lowercase()
|
||||||
|
if (err.contains("oauth") || err.contains("auth") || err.contains("token")) {
|
||||||
|
return AUTH_REQUIRED
|
||||||
|
}
|
||||||
|
return ERROR
|
||||||
|
}
|
||||||
|
if (!serverError.isNullOrBlank()) {
|
||||||
|
val err = serverError.lowercase()
|
||||||
|
if (err.contains("oauth") || err.contains("auth") || err.contains("token")) {
|
||||||
|
return AUTH_REQUIRED
|
||||||
|
}
|
||||||
|
return ERROR
|
||||||
|
}
|
||||||
|
if (serverStatus != null) {
|
||||||
|
return when (serverStatus.lowercase()) {
|
||||||
|
"running", "ok", "connected", "healthy" -> HEALTHY
|
||||||
|
"error", "failed" -> ERROR
|
||||||
|
else -> UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,11 +22,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Build
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Key
|
import androidx.compose.material.icons.filled.Key
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Science
|
import androidx.compose.material.icons.filled.Science
|
||||||
import androidx.compose.material.icons.filled.Storage
|
import androidx.compose.material.icons.filled.Storage
|
||||||
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -41,6 +45,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.SecondaryTabRow
|
import androidx.compose.material3.SecondaryTabRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -54,8 +59,10 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
@@ -90,6 +97,7 @@ fun McpServersScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
var showDetail by remember { mutableStateOf<McpServer?>(null) }
|
var showDetail by remember { mutableStateOf<McpServer?>(null) }
|
||||||
|
|
||||||
@@ -123,6 +131,32 @@ fun McpServersScreen(
|
|||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
isRefreshing = state.isLoading,
|
isRefreshing = state.isLoading,
|
||||||
onRefresh = { viewModel.loadServers() },
|
onRefresh = { viewModel.loadServers() },
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.testAllServers() },
|
||||||
|
enabled = !state.isTestingAll && state.servers.any { it.enabled },
|
||||||
|
) {
|
||||||
|
if (state.isTestingAll) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Science,
|
||||||
|
contentDescription = stringResource(R.string.mcp_servers_action_test_all),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.toggleImportDialog() },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.UploadFile,
|
||||||
|
contentDescription = stringResource(R.string.mcp_servers_action_import_json),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
when {
|
when {
|
||||||
state.isLoading && state.servers.isEmpty() -> {
|
state.isLoading && state.servers.isEmpty() -> {
|
||||||
@@ -272,6 +306,95 @@ fun McpServersScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.showImportDialog) {
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.toggleImportDialog() },
|
||||||
|
title = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.UploadFile,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
Text(stringResource(R.string.mcp_servers_import_dialog_title))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_import_dialog_desc),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.sm))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
val clip = clipboardManager.getText()?.text
|
||||||
|
if (!clip.isNullOrBlank()) {
|
||||||
|
viewModel.updateImportJsonInput(clip)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mcp_servers_import_dialog_paste),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.importJsonInput,
|
||||||
|
onValueChange = viewModel::updateImportJsonInput,
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(180.dp),
|
||||||
|
maxLines = 10,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.submitImportJson() },
|
||||||
|
enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(),
|
||||||
|
) {
|
||||||
|
if (state.isImportingJson) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(stringResource(R.string.mcp_servers_import_dialog_importing))
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.mcp_servers_action_submit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
OutlinedButton(onClick = { viewModel.toggleImportDialog() }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section header (MCP-specific: distinct icon + neutral title so it reads
|
// ── Section header (MCP-specific: distinct icon + neutral title so it reads
|
||||||
@@ -325,6 +448,19 @@ private fun AddServerSection(
|
|||||||
icon = Icons.Filled.Add,
|
icon = Icons.Filled.Add,
|
||||||
title = stringResource(R.string.mcp_servers_add_server),
|
title = stringResource(R.string.mcp_servers_add_server),
|
||||||
trailing = {
|
trailing = {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.toggleImportDialog() },
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.textButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(stringResource(R.string.mcp_servers_action_import_json))
|
||||||
|
}
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { viewModel.toggleAddForm() },
|
onClick = { viewModel.toggleAddForm() },
|
||||||
colors =
|
colors =
|
||||||
@@ -335,6 +471,7 @@ private fun AddServerSection(
|
|||||||
) {
|
) {
|
||||||
Text(if (state.showAddForm) "Hide" else "New")
|
Text(if (state.showAddForm) "Hide" else "New")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -463,6 +600,9 @@ private fun ServerCard(
|
|||||||
onOpenBrowser: (String) -> Unit,
|
onOpenBrowser: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var showEnv by remember { mutableStateOf(false) }
|
var showEnv by remember { mutableStateOf(false) }
|
||||||
|
val isTesting = server.name in state.testingServers
|
||||||
|
val testResult = state.serverTestResults[server.name]
|
||||||
|
val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||||
@@ -491,13 +631,77 @@ private fun ServerCard(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
when (healthStatus) {
|
||||||
|
McpHealthStatus.HEALTHY -> {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.mcp_servers_status_healthy),
|
||||||
|
status = StatusBadgeType.SUCCESS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
McpHealthStatus.AUTH_REQUIRED -> {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.mcp_servers_status_needs_auth),
|
||||||
|
status = StatusBadgeType.WARNING,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
McpHealthStatus.ERROR -> {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.mcp_servers_status_error),
|
||||||
|
status = StatusBadgeType.ERROR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
McpHealthStatus.TESTING -> {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.mcp_servers_status_testing),
|
||||||
|
status = StatusBadgeType.INFO,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
McpHealthStatus.UNKNOWN -> {
|
||||||
ServerStatusBadge(server.status)
|
ServerStatusBadge(server.status)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
|
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Tool count & schema token overhead badge (issue #1029)
|
||||||
|
val toolInfos = testResult?.tools
|
||||||
|
val toolCount = toolInfos?.size ?: server.tools?.size
|
||||||
|
if (toolCount != null && toolCount > 0) {
|
||||||
|
val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) }
|
||||||
|
val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
|
||||||
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Build,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Switch(
|
Switch(
|
||||||
checked = server.enabled,
|
checked = server.enabled,
|
||||||
@@ -556,13 +760,23 @@ private fun ServerCard(
|
|||||||
}
|
}
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { viewModel.testServer(server.name) },
|
onClick = { viewModel.testServer(server.name) },
|
||||||
|
enabled = !isTesting,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||||
) {
|
) {
|
||||||
|
if (isTesting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
|
Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1)
|
||||||
|
} else {
|
||||||
Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp))
|
Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
Spacer(modifier = Modifier.width(spacing.xs))
|
||||||
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
|
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
|
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
|
||||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.model.McpCatalogEntry
|
|||||||
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
|
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
|
||||||
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
||||||
import com.m57.hermescontrol.data.model.McpServer
|
import com.m57.hermescontrol.data.model.McpServer
|
||||||
|
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
@@ -14,6 +15,8 @@ import com.m57.hermescontrol.data.remote.safeApiCall
|
|||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -38,6 +41,14 @@ data class McpServersUiState(
|
|||||||
val addServerAuth: String = "none", // "none" | "header" | "oauth"
|
val addServerAuth: String = "none", // "none" | "header" | "oauth"
|
||||||
val addServerBearerToken: String = "",
|
val addServerBearerToken: String = "",
|
||||||
val addingServer: Boolean = false,
|
val addingServer: Boolean = false,
|
||||||
|
// JSON Import (issue #1029)
|
||||||
|
val showImportDialog: Boolean = false,
|
||||||
|
val importJsonInput: String = "",
|
||||||
|
val isImportingJson: Boolean = false,
|
||||||
|
// Batch health & tool test results (issue #1029)
|
||||||
|
val serverTestResults: Map<String, McpServerTestResponse> = emptyMap(),
|
||||||
|
val testingServers: Set<String> = emptySet(),
|
||||||
|
val isTestingAll: Boolean = false,
|
||||||
// Env vars for editing
|
// Env vars for editing
|
||||||
val editingEnvFor: String? = null,
|
val editingEnvFor: String? = null,
|
||||||
val envKeyInput: String = "",
|
val envKeyInput: String = "",
|
||||||
@@ -69,6 +80,8 @@ class McpServersViewModel :
|
|||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
servers = data.servers.orEmpty(),
|
servers = data.servers.orEmpty(),
|
||||||
|
serverTestResults = emptyMap(),
|
||||||
|
testingServers = emptySet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -126,22 +139,104 @@ class McpServersViewModel :
|
|||||||
|
|
||||||
fun testServer(name: String) {
|
fun testServer(name: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(toastMessage = "Testing server '$name'…") }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
testingServers = it.testingServers + name,
|
||||||
|
toastMessage = "Testing server '$name'…",
|
||||||
|
)
|
||||||
|
}
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
safeApiCall { ApiClient.hermesApi.testMcpServer(name) }
|
safeApiCall { ApiClient.hermesApi.testMcpServer(name) }
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Server '$name' tested — OK") }
|
val response = result.data
|
||||||
|
val toolCount = response.tools.size
|
||||||
|
val tokenEst = McpTokenEstimator.estimateTokens(response.tools)
|
||||||
|
val tokenStr = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
serverTestResults = state.serverTestResults + (name to response),
|
||||||
|
testingServers = state.testingServers - name,
|
||||||
|
toastMessage =
|
||||||
|
if (response.ok) {
|
||||||
|
"Server '$name' tested — OK ($tokenStr)"
|
||||||
|
} else {
|
||||||
|
"Server '$name' test failed: ${response.error ?: "unknown error"}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Server '$name' test failed: ${result.error.message}") }
|
val errResp = McpServerTestResponse(ok = false, error = result.error.message)
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
serverTestResults = state.serverTestResults + (name to errResp),
|
||||||
|
testingServers = state.testingServers - name,
|
||||||
|
toastMessage = "Server '$name' test failed: ${result.error.message}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun testAllServers() {
|
||||||
|
val enabledServers = _uiState.value.servers.filter { it.enabled }
|
||||||
|
if (enabledServers.isEmpty()) {
|
||||||
|
_uiState.update { it.copy(toastMessage = "No enabled servers to test") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val names = enabledServers.map { it.name }.toSet()
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isTestingAll = true,
|
||||||
|
testingServers = it.testingServers + names,
|
||||||
|
toastMessage = "Testing ${enabledServers.size} server(s)…",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val testJobs =
|
||||||
|
enabledServers.map { server ->
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
val res = safeApiCall { ApiClient.hermesApi.testMcpServer(server.name) }
|
||||||
|
server.name to res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val results = testJobs.awaitAll()
|
||||||
|
val newResults = mutableMapOf<String, McpServerTestResponse>()
|
||||||
|
var passCount = 0
|
||||||
|
|
||||||
|
for ((name, result) in results) {
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
newResults[name] = result.data
|
||||||
|
if (result.data.ok) passCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
newResults[name] = McpServerTestResponse(ok = false, error = result.error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val failCount = enabledServers.size - passCount
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
isTestingAll = false,
|
||||||
|
serverTestResults = state.serverTestResults + newResults,
|
||||||
|
testingServers = state.testingServers - names,
|
||||||
|
toastMessage =
|
||||||
|
"Tested ${enabledServers.size} servers: $passCount passed, $failCount failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteServer(name: String) {
|
fun deleteServer(name: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -162,6 +257,64 @@ class McpServersViewModel :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── JSON Import (issue #1029) ────────────────────────────
|
||||||
|
|
||||||
|
fun toggleImportDialog() {
|
||||||
|
_uiState.update { it.copy(showImportDialog = !it.showImportDialog, importJsonInput = "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateImportJsonInput(v: String) {
|
||||||
|
_uiState.update { it.copy(importJsonInput = v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitImportJson() {
|
||||||
|
val input = _uiState.value.importJsonInput
|
||||||
|
val requests = McpJsonParser.parse(input)
|
||||||
|
if (requests.isEmpty()) {
|
||||||
|
_uiState.update { it.copy(toastMessage = "Invalid JSON or no MCP servers found in input") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update { it.copy(isImportingJson = true) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val importJobs =
|
||||||
|
requests.map { req ->
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
val result = safeApiCall { ApiClient.hermesApi.addMcpServer(req) }
|
||||||
|
req to result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val results = importJobs.awaitAll()
|
||||||
|
var successCount = 0
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
|
||||||
|
for ((req, result) in results) {
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> successCount++
|
||||||
|
is NetworkResult.Failure -> errors.add("${req.name}: ${result.error.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update { state ->
|
||||||
|
val errSummary = errors.joinToString("; ")
|
||||||
|
val msg =
|
||||||
|
when {
|
||||||
|
errors.isEmpty() -> "Successfully imported $successCount server(s)"
|
||||||
|
successCount > 0 -> "Imported $successCount server(s), ${errors.size} failed: $errSummary"
|
||||||
|
else -> "Failed to import: $errSummary"
|
||||||
|
}
|
||||||
|
state.copy(
|
||||||
|
isImportingJson = false,
|
||||||
|
showImportDialog = false,
|
||||||
|
importJsonInput = "",
|
||||||
|
toastMessage = msg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loadServers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Add server form ──────────────────────────────────────
|
// ── Add server form ──────────────────────────────────────
|
||||||
|
|
||||||
fun toggleAddForm() {
|
fun toggleAddForm() {
|
||||||
@@ -456,6 +609,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 +643,7 @@ class McpServersViewModel :
|
|||||||
}
|
}
|
||||||
loadServers()
|
loadServers()
|
||||||
}
|
}
|
||||||
|
|
||||||
"error" -> {
|
"error" -> {
|
||||||
polling = false
|
polling = false
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@@ -498,15 +653,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package com.m57.hermescontrol.ui.memory
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.data.model.LearningGraphResponse
|
import com.m57.hermescontrol.data.model.LearningGraphResponse
|
||||||
|
import com.m57.hermescontrol.data.model.MemoryResetRequest
|
||||||
import com.m57.hermescontrol.data.model.MemoryResponse
|
import com.m57.hermescontrol.data.model.MemoryResponse
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
|
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -30,19 +32,38 @@ 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()
|
||||||
|
|
||||||
fun load() {
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
ProfileSwitchCoordinator.switched.collect {
|
||||||
|
load(silent = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
ProfileSwitchCoordinator.connectionSwitched.collect {
|
||||||
|
load(silent = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(silent: Boolean = false) {
|
||||||
if (_uiState.value.isLoading) return
|
if (_uiState.value.isLoading) return
|
||||||
|
if (!silent) {
|
||||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
}
|
||||||
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,
|
||||||
@@ -50,6 +71,7 @@ class MemoryViewModel : ViewModel(), ToastHost {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
loadLearningGraph()
|
loadLearningGraph()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,16 +89,18 @@ class MemoryViewModel : ViewModel(), ToastHost {
|
|||||||
if (_uiState.value.resetting != null) return
|
if (_uiState.value.resetting != null) return
|
||||||
_uiState.update { it.copy(resetting = target) }
|
_uiState.update { it.copy(resetting = target) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = safeApiCall { ApiClient.hermesApi.resetMemory(mapOf("target" to 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,
|
||||||
@@ -84,6 +108,7 @@ class MemoryViewModel : ViewModel(), ToastHost {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
@@ -403,6 +405,7 @@ private fun ProviderFieldRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+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,
|
||||||
@@ -102,6 +110,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Field text values; secrets start blank (write-only, never echoed). */
|
/** Field text values; secrets start blank (write-only, never echoed). */
|
||||||
private fun seedEdits(config: MemoryProviderConfigResponse): Map<String, String> =
|
private fun seedEdits(config: MemoryProviderConfigResponse): Map<String, String> =
|
||||||
@@ -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,7 +163,9 @@ 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,
|
||||||
@@ -162,6 +173,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
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,7 +205,9 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is NetworkResult.Failure ->
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
runningSetup = false,
|
runningSetup = false,
|
||||||
@@ -201,6 +215,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
refreshStatus(name)
|
refreshStatus(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -49,6 +51,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -131,6 +134,29 @@ fun ProfilesScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!isBuildingProfile) {
|
if (!isBuildingProfile) {
|
||||||
|
if (state.hasHiddenProfiles) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.toggleShowHidden() },
|
||||||
|
modifier = Modifier.testTag("profiles_action_toggle_hidden"),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (state.showHidden) {
|
||||||
|
Icons.Default.VisibilityOff
|
||||||
|
} else {
|
||||||
|
Icons.Default.Visibility
|
||||||
|
},
|
||||||
|
contentDescription =
|
||||||
|
stringResource(
|
||||||
|
if (state.showHidden) {
|
||||||
|
R.string.profiles_hide_hidden
|
||||||
|
} else {
|
||||||
|
R.string.profiles_show_hidden
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
isBuildingProfile = true
|
isBuildingProfile = true
|
||||||
viewModel.loadBuilderData()
|
viewModel.loadBuilderData()
|
||||||
@@ -173,6 +199,16 @@ fun ProfilesScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.displayProfiles.isEmpty() -> {
|
||||||
|
EmptyState(
|
||||||
|
title = stringResource(R.string.profiles_empty_title),
|
||||||
|
subtitle = stringResource(R.string.profiles_all_hidden_desc),
|
||||||
|
onAction = { viewModel.toggleShowHidden() },
|
||||||
|
actionLabel = stringResource(R.string.profiles_show_hidden),
|
||||||
|
modifier = Modifier.padding(paddingValues),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
if (state.isLoading && state.profiles.isEmpty()) {
|
if (state.isLoading && state.profiles.isEmpty()) {
|
||||||
@@ -194,7 +230,7 @@ fun ProfilesScreen(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
items(state.profiles, key = { it.name }) { profile ->
|
items(state.displayProfiles, key = { it.name }) { profile ->
|
||||||
val isActive = profile.name == state.activeProfileName
|
val isActive = profile.name == state.activeProfileName
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -225,11 +261,37 @@ fun ProfilesScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = profile.name.replaceFirstChar { it.uppercase() },
|
text = profile.name.replaceFirstChar { it.uppercase() },
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
val isProfileHidden =
|
||||||
|
profile.name in state.hiddenProfiles ||
|
||||||
|
profile.botMeta()?.hidden == true
|
||||||
|
if (isProfileHidden) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
).padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
R.string.profiles_hidden_badge,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
val descriptionText =
|
val descriptionText =
|
||||||
if (!profile.description.isNullOrBlank()) {
|
if (!profile.description.isNullOrBlank()) {
|
||||||
profile.description
|
profile.description
|
||||||
@@ -376,6 +438,30 @@ fun ProfilesScreen(
|
|||||||
setupCmdProfileName = profile.name
|
setupCmdProfileName = profile.name
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
val isProfileHidden =
|
||||||
|
profile.name in state.hiddenProfiles ||
|
||||||
|
profile.botMeta()?.hidden == true
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (isProfileHidden) {
|
||||||
|
R.string.profiles_action_unhide
|
||||||
|
} else {
|
||||||
|
R.string.profiles_action_hide
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
if (isProfileHidden) {
|
||||||
|
viewModel.unhideProfile(profile.name)
|
||||||
|
} else {
|
||||||
|
viewModel.hideProfile(profile.name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
if (profile.is_default != true) {
|
if (profile.is_default != true) {
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ data class ProfilesUiState(
|
|||||||
val isLoadingSoul: Boolean = false,
|
val isLoadingSoul: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val toastMessage: String? = null,
|
val toastMessage: String? = null,
|
||||||
|
val showHidden: Boolean = false,
|
||||||
|
val hiddenProfiles: Set<String> = emptySet(),
|
||||||
// Rename / delete / auto-describe / setup-command states
|
// Rename / delete / auto-describe / setup-command states
|
||||||
val isAutoDescribing: Boolean = false,
|
val isAutoDescribing: Boolean = false,
|
||||||
val setupCommand: String? = null,
|
val setupCommand: String? = null,
|
||||||
@@ -50,7 +52,20 @@ data class ProfilesUiState(
|
|||||||
val availableSkills: List<Skill> = emptyList(),
|
val availableSkills: List<Skill> = emptyList(),
|
||||||
val hubSearchResults: List<HubSkill> = emptyList(),
|
val hubSearchResults: List<HubSkill> = emptyList(),
|
||||||
val isSearchingHub: Boolean = false,
|
val isSearchingHub: Boolean = false,
|
||||||
)
|
) {
|
||||||
|
val hasHiddenProfiles: Boolean
|
||||||
|
get() = profiles.any { it.name in hiddenProfiles || it.botMeta()?.hidden == true }
|
||||||
|
|
||||||
|
val displayProfiles: List<ProfileInfo>
|
||||||
|
get() =
|
||||||
|
profiles.filter { profile ->
|
||||||
|
if (showHidden) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
!(profile.name in hiddenProfiles || profile.botMeta()?.hidden == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ProfilesViewModel(
|
class ProfilesViewModel(
|
||||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
@@ -59,6 +74,34 @@ class ProfilesViewModel(
|
|||||||
private val _uiState = MutableStateFlow(ProfilesUiState())
|
private val _uiState = MutableStateFlow(ProfilesUiState())
|
||||||
val uiState: StateFlow<ProfilesUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ProfilesUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
_uiState.update { it.copy(hiddenProfiles = AuthManager.getHiddenProfiles().toSet()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleShowHidden() {
|
||||||
|
_uiState.update { it.copy(showHidden = !it.showHidden) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideProfile(name: String) {
|
||||||
|
AuthManager.hideProfile(name)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||||
|
toastMessage = "Profile $name hidden",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unhideProfile(name: String) {
|
||||||
|
AuthManager.unhideProfile(name)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||||
|
toastMessage = "Profile $name unhidden",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadProfiles() {
|
fun loadProfiles() {
|
||||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -77,6 +120,7 @@ class ProfilesViewModel(
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
profiles = profilesResult.data.profiles.orEmpty(),
|
profiles = profilesResult.data.profiles.orEmpty(),
|
||||||
activeProfileName = activeResult.data.active,
|
activeProfileName = activeResult.data.active,
|
||||||
|
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import androidx.compose.material.icons.filled.PushPin
|
|||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.SelectAll
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
import androidx.compose.material.icons.filled.Terminal
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.BadgedBox
|
import androidx.compose.material3.BadgedBox
|
||||||
@@ -59,6 +61,7 @@ import androidx.compose.material3.DropdownMenu
|
|||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@@ -290,7 +293,7 @@ private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
flattenSessionTree(state.sessions)
|
flattenSessionTree(state.displaySessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@@ -313,6 +316,7 @@ fun SessionsScreen(
|
|||||||
state.isSearchMode,
|
state.isSearchMode,
|
||||||
state.searchQuery,
|
state.searchQuery,
|
||||||
state.sessions,
|
state.sessions,
|
||||||
|
state.showHidden,
|
||||||
state.searchResults,
|
state.searchResults,
|
||||||
) {
|
) {
|
||||||
displayedSessions(state)
|
displayedSessions(state)
|
||||||
@@ -521,6 +525,29 @@ fun SessionsScreen(
|
|||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
isRefreshing = state.isLoading,
|
isRefreshing = state.isLoading,
|
||||||
onRefresh = { viewModel.loadSessions() },
|
onRefresh = { viewModel.loadSessions() },
|
||||||
|
actions = {
|
||||||
|
if (state.hasHiddenSessions) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.toggleShowHidden() },
|
||||||
|
modifier = Modifier.testTag("sessions_action_toggle_hidden"),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (state.showHidden) {
|
||||||
|
Icons.Filled.VisibilityOff
|
||||||
|
} else {
|
||||||
|
Icons.Filled.Visibility
|
||||||
|
},
|
||||||
|
contentDescription =
|
||||||
|
if (state.showHidden) {
|
||||||
|
stringResource(R.string.sessions_hide_hidden)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.sessions_show_hidden)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
// Single scrolling column: search bar pinned on top, list fills below.
|
// Single scrolling column: search bar pinned on top, list fills below.
|
||||||
@@ -685,6 +712,16 @@ fun SessionsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.displaySessions.isEmpty() -> {
|
||||||
|
EmptyState(
|
||||||
|
title = stringResource(R.string.history_empty_title),
|
||||||
|
subtitle = stringResource(R.string.sessions_all_hidden_desc),
|
||||||
|
icon = Icons.Filled.VisibilityOff,
|
||||||
|
actionLabel = stringResource(R.string.sessions_show_hidden),
|
||||||
|
onAction = { viewModel.toggleShowHidden() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// ── Stats row ───────────────────────────────────────
|
// ── Stats row ───────────────────────────────────────
|
||||||
@@ -767,11 +804,15 @@ fun SessionsScreen(
|
|||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val info = listState.layoutInfo
|
val info = listState.layoutInfo
|
||||||
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
|
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||||
lastVisible >= 0 &&
|
if (lastVisible < 0 || info.totalItemsCount == 0) {
|
||||||
info.totalItemsCount >= AUTO_LOAD_THRESHOLD &&
|
false
|
||||||
|
} else if (info.totalItemsCount <= AUTO_LOAD_THRESHOLD) {
|
||||||
|
lastVisible >= info.totalItemsCount - 1
|
||||||
|
} else {
|
||||||
lastVisible >= info.totalItemsCount - AUTO_LOAD_THRESHOLD
|
lastVisible >= info.totalItemsCount - AUTO_LOAD_THRESHOLD
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
nearListEnd,
|
nearListEnd,
|
||||||
state.isLoadingMore,
|
state.isLoadingMore,
|
||||||
@@ -801,6 +842,7 @@ fun SessionsScreen(
|
|||||||
isSelected = session.id in state.selectedIds,
|
isSelected = session.id in state.selectedIds,
|
||||||
isDeleting = session.id in state.deletingSessionIds,
|
isDeleting = session.id in state.deletingSessionIds,
|
||||||
isPinned = session.pinned == true,
|
isPinned = session.pinned == true,
|
||||||
|
isHidden = session.hidden == true,
|
||||||
highlightBackground = primaryContainer,
|
highlightBackground = primaryContainer,
|
||||||
highlightForeground = onPrimaryContainer,
|
highlightForeground = onPrimaryContainer,
|
||||||
onCardClick = {
|
onCardClick = {
|
||||||
@@ -822,6 +864,7 @@ fun SessionsScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onTogglePin = { viewModel.togglePin(session.id) },
|
onTogglePin = { viewModel.togglePin(session.id) },
|
||||||
|
onToggleHide = { viewModel.toggleHide(session.id) },
|
||||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -969,6 +1012,7 @@ private fun SessionCard(
|
|||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
isDeleting: Boolean,
|
isDeleting: Boolean,
|
||||||
isPinned: Boolean,
|
isPinned: Boolean,
|
||||||
|
isHidden: Boolean = false,
|
||||||
highlightBackground: Color,
|
highlightBackground: Color,
|
||||||
highlightForeground: Color,
|
highlightForeground: Color,
|
||||||
onCardClick: () -> Unit,
|
onCardClick: () -> Unit,
|
||||||
@@ -976,6 +1020,7 @@ private fun SessionCard(
|
|||||||
onSelect: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
onRename: () -> Unit,
|
onRename: () -> Unit,
|
||||||
onTogglePin: () -> Unit,
|
onTogglePin: () -> Unit,
|
||||||
|
onToggleHide: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
@@ -1095,6 +1140,13 @@ private fun SessionCard(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
if (session.hidden == true) {
|
||||||
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.sessions_hidden_badge),
|
||||||
|
status = StatusBadgeType.NEUTRAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!session.status.isNullOrBlank()) {
|
if (!session.status.isNullOrBlank()) {
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
StatusBadge(
|
StatusBadge(
|
||||||
@@ -1116,6 +1168,11 @@ private fun SessionCard(
|
|||||||
menuExpanded = false
|
menuExpanded = false
|
||||||
onTogglePin()
|
onTogglePin()
|
||||||
},
|
},
|
||||||
|
isHidden = isHidden,
|
||||||
|
onToggleHide = {
|
||||||
|
menuExpanded = false
|
||||||
|
onToggleHide()
|
||||||
|
},
|
||||||
onSelect = {
|
onSelect = {
|
||||||
menuExpanded = false
|
menuExpanded = false
|
||||||
onSelect()
|
onSelect()
|
||||||
@@ -1301,6 +1358,8 @@ private fun SessionActionMenu(
|
|||||||
isDeleting: Boolean,
|
isDeleting: Boolean,
|
||||||
isPinned: Boolean = false,
|
isPinned: Boolean = false,
|
||||||
onTogglePin: (() -> Unit)? = null,
|
onTogglePin: (() -> Unit)? = null,
|
||||||
|
isHidden: Boolean = false,
|
||||||
|
onToggleHide: (() -> Unit)? = null,
|
||||||
onSelect: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
onRename: () -> Unit,
|
onRename: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
@@ -1324,6 +1383,33 @@ private fun SessionActionMenu(
|
|||||||
onClick = onTogglePin,
|
onClick = onTogglePin,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (onToggleHide != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (isHidden) {
|
||||||
|
R.string.sessions_action_unhide
|
||||||
|
} else {
|
||||||
|
R.string.sessions_action_hide
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (isHidden) {
|
||||||
|
Icons.Filled.Visibility
|
||||||
|
} else {
|
||||||
|
Icons.Filled.VisibilityOff
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onToggleHide,
|
||||||
|
)
|
||||||
|
}
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.sessions_action_select)) },
|
text = { Text(stringResource(R.string.sessions_action_select)) },
|
||||||
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
|
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
|
||||||
|
|||||||
@@ -89,8 +89,15 @@ data class SessionsUiState(
|
|||||||
val isSearching: Boolean = false,
|
val isSearching: Boolean = false,
|
||||||
val searchResults: List<SessionSearchResult> = emptyList(),
|
val searchResults: List<SessionSearchResult> = emptyList(),
|
||||||
val searchError: String? = null,
|
val searchError: String? = null,
|
||||||
|
val showHidden: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||||
|
|
||||||
|
val hasHiddenSessions: Boolean
|
||||||
|
get() = sessions.any { it.hidden == true }
|
||||||
|
|
||||||
|
val displaySessions: List<SessionInfo>
|
||||||
|
get() = if (showHidden) sessions else sessions.filter { it.hidden != true }
|
||||||
}
|
}
|
||||||
|
|
||||||
class SessionsViewModel :
|
class SessionsViewModel :
|
||||||
@@ -209,11 +216,6 @@ class SessionsViewModel :
|
|||||||
val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size
|
val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoadingMore = false,
|
isLoadingMore = false,
|
||||||
// distinctBy guards offset-pagination churn: if a new
|
|
||||||
// session lands on top between page loads, offsets
|
|
||||||
// shift and the next page can repeat an id we already
|
|
||||||
// have — LazyColumn would crash on the duplicate key.
|
|
||||||
// pinnedFirst keeps pinned sessions above the rest.
|
|
||||||
sessions = newSessions,
|
sessions = newSessions,
|
||||||
total = if (hasMore) data.total else newSessions.size,
|
total = if (hasMore) data.total else newSessions.size,
|
||||||
hasMore = hasMore,
|
hasMore = hasMore,
|
||||||
@@ -315,6 +317,10 @@ class SessionsViewModel :
|
|||||||
|
|
||||||
// ── Bulk selection ───────────────────────────────────────────────────
|
// ── Bulk selection ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun toggleShowHidden() {
|
||||||
|
_uiState.update { it.copy(showHidden = !it.showHidden) }
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleSelecting() {
|
fun toggleSelecting() {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -455,6 +461,49 @@ class SessionsViewModel :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Hide / unhide (issue #1019) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the hidden flag on a session via PATCH /api/sessions/{id} ({hidden}).
|
||||||
|
*/
|
||||||
|
fun toggleHide(sessionId: String) {
|
||||||
|
val session = _uiState.value.sessions.find { it.id == sessionId } ?: return
|
||||||
|
val targetHidden = session.hidden != true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result =
|
||||||
|
safeApiCall {
|
||||||
|
ApiClient.hermesApi.setSessionHidden(
|
||||||
|
sessionId = sessionId,
|
||||||
|
body = SessionRenameRequest(hidden = targetHidden),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
sessions =
|
||||||
|
it.sessions
|
||||||
|
.map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s }
|
||||||
|
.pinnedFirst(),
|
||||||
|
toastMessage =
|
||||||
|
if (targetHidden) {
|
||||||
|
"Session hidden"
|
||||||
|
} else {
|
||||||
|
"Session unhidden"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(toastMessage = "Hide failed: ${result.error.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Delete (single) ──────────────────────────────────────────────────
|
// ── Delete (single) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fun requestDeleteSession(sessionId: String) {
|
fun requestDeleteSession(sessionId: String) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ internal fun supportedLanguages(): List<Pair<String, String>> =
|
|||||||
listOf(
|
listOf(
|
||||||
LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system),
|
LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system),
|
||||||
"en" to stringResource(R.string.language_english),
|
"en" to stringResource(R.string.language_english),
|
||||||
|
"ar" to stringResource(R.string.language_arabic),
|
||||||
"zh" to stringResource(R.string.language_chinese),
|
"zh" to stringResource(R.string.language_chinese),
|
||||||
"ko" to stringResource(R.string.language_korean),
|
"ko" to stringResource(R.string.language_korean),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -694,15 +706,23 @@ private fun ToolsetProvider.statusTextRes(): Int =
|
|||||||
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,7 +103,7 @@ 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}",
|
||||||
@@ -110,6 +112,7 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleProviderExpanded(name: String) {
|
fun toggleProviderExpanded(name: String) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@@ -150,7 +153,7 @@ 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}",
|
||||||
@@ -159,6 +162,7 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun saveEnvVar(
|
fun saveEnvVar(
|
||||||
key: String,
|
key: String,
|
||||||
@@ -176,14 +180,15 @@ 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}",
|
||||||
@@ -192,6 +197,7 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun clearEnvVar(key: String) {
|
fun clearEnvVar(key: String) {
|
||||||
_uiState.update { it.copy(deletingEnvKey = key) }
|
_uiState.update { it.copy(deletingEnvKey = key) }
|
||||||
@@ -199,15 +205,16 @@ 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}",
|
||||||
@@ -216,21 +223,24 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun revealEnvVar(key: String) {
|
fun revealEnvVar(key: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun hideEnvVar(key: String) {
|
fun hideEnvVar(key: String) {
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ object LocaleContextWrapper {
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
setLocale(locale)
|
setLocale(locale)
|
||||||
}
|
}
|
||||||
|
setLayoutDirection(locale)
|
||||||
}
|
}
|
||||||
return base.createConfigurationContext(newConfig)
|
return base.createConfigurationContext(newConfig)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -240,6 +240,14 @@
|
|||||||
<string name="theme_preset_catppuccin">Catppuccin</string>
|
<string name="theme_preset_catppuccin">Catppuccin</string>
|
||||||
<string name="theme_preset_amoled">AMOLED 블랙 (다크 전용)</string>
|
<string name="theme_preset_amoled">AMOLED 블랙 (다크 전용)</string>
|
||||||
|
|
||||||
|
<!-- Language -->
|
||||||
|
<string name="settings_item_language">언어</string>
|
||||||
|
<string name="language_system">시스템 기본값</string>
|
||||||
|
<string name="language_english">English</string>
|
||||||
|
<string name="language_arabic">العربية</string>
|
||||||
|
<string name="language_chinese">简体中文</string>
|
||||||
|
<string name="language_korean">한국어</string>
|
||||||
|
|
||||||
<!-- Connect Screen -->
|
<!-- Connect Screen -->
|
||||||
<string name="connect_selected_profile">프로필: %1$s</string>
|
<string name="connect_selected_profile">프로필: %1$s</string>
|
||||||
<string name="connect_action_select_profile">저장된 프로필 선택</string>
|
<string name="connect_action_select_profile">저장된 프로필 선택</string>
|
||||||
@@ -406,6 +414,12 @@
|
|||||||
<string name="sessions_action_select_all">전체 선택</string>
|
<string name="sessions_action_select_all">전체 선택</string>
|
||||||
<string name="sessions_action_deselect_all">선택 해제</string>
|
<string name="sessions_action_deselect_all">선택 해제</string>
|
||||||
<string name="sessions_action_delete_n">%1$d개 삭제</string>
|
<string name="sessions_action_delete_n">%1$d개 삭제</string>
|
||||||
|
<string name="sessions_action_hide">숨기기</string>
|
||||||
|
<string name="sessions_action_unhide">숨김 해제</string>
|
||||||
|
<string name="sessions_show_hidden">숨겨진 세션 표시</string>
|
||||||
|
<string name="sessions_hide_hidden">숨겨진 세션 숨기기</string>
|
||||||
|
<string name="sessions_hidden_badge">숨김</string>
|
||||||
|
<string name="sessions_all_hidden_desc">모든 세션이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 세션을 표시하세요.</string>
|
||||||
|
|
||||||
<!-- Toolsets Screen -->
|
<!-- Toolsets Screen -->
|
||||||
<string name="toolsets_empty_title">툴셋이 없습니다</string>
|
<string name="toolsets_empty_title">툴셋이 없습니다</string>
|
||||||
@@ -453,6 +467,12 @@
|
|||||||
<string name="profiles_action_set_model">모델 설정</string>
|
<string name="profiles_action_set_model">모델 설정</string>
|
||||||
<string name="profiles_action_clone">복제</string>
|
<string name="profiles_action_clone">복제</string>
|
||||||
<string name="profiles_action_edit_description">설명 편집</string>
|
<string name="profiles_action_edit_description">설명 편집</string>
|
||||||
|
<string name="profiles_action_hide">프로필 숨기기</string>
|
||||||
|
<string name="profiles_action_unhide">프로필 숨김 해제</string>
|
||||||
|
<string name="profiles_show_hidden">숨겨진 프로필 표시</string>
|
||||||
|
<string name="profiles_hide_hidden">숨겨진 프로필 숨기기</string>
|
||||||
|
<string name="profiles_hidden_badge">숨김</string>
|
||||||
|
<string name="profiles_all_hidden_desc">모든 프로필이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 프로필을 표시하세요.</string>
|
||||||
<string name="profiles_content_desc_active">활성 프로필</string>
|
<string name="profiles_content_desc_active">활성 프로필</string>
|
||||||
<string name="profiles_title_edit_soul">소울 프로필 편집: %1$s</string>
|
<string name="profiles_title_edit_soul">소울 프로필 편집: %1$s</string>
|
||||||
<string name="profiles_title_set_model">모델 설정: %1$s</string>
|
<string name="profiles_title_set_model">모델 설정: %1$s</string>
|
||||||
@@ -571,6 +591,16 @@
|
|||||||
<string name="mcp_servers_label_transport">전송: %1$s</string>
|
<string name="mcp_servers_label_transport">전송: %1$s</string>
|
||||||
<string name="mcp_servers_label_command">명령어:</string>
|
<string name="mcp_servers_label_command">명령어:</string>
|
||||||
<string name="mcp_servers_action_test">테스트</string>
|
<string name="mcp_servers_action_test">테스트</string>
|
||||||
|
<string name="mcp_servers_action_test_all">모두 테스트</string>
|
||||||
|
<string name="mcp_servers_action_import_json">JSON 가져오기</string>
|
||||||
|
<string name="mcp_servers_import_dialog_title">MCP 서버 가져오기</string>
|
||||||
|
<string name="mcp_servers_import_dialog_desc">Claude Desktop / Cursor mcpServers JSON 스니펫 또는 서버 정의를 붙여넣으세요.</string>
|
||||||
|
<string name="mcp_servers_import_dialog_paste">클립보드 붙여넣기</string>
|
||||||
|
<string name="mcp_servers_import_dialog_importing">가져오는 중…</string>
|
||||||
|
<string name="mcp_servers_status_healthy">정상</string>
|
||||||
|
<string name="mcp_servers_status_needs_auth">인증 필요</string>
|
||||||
|
<string name="mcp_servers_status_error">오류</string>
|
||||||
|
<string name="mcp_servers_status_testing">테스트 중…</string>
|
||||||
<string name="mcp_servers_add_server">서버 추가</string>
|
<string name="mcp_servers_add_server">서버 추가</string>
|
||||||
<string name="mcp_servers_add_http">HTTP URL</string>
|
<string name="mcp_servers_add_http">HTTP URL</string>
|
||||||
<string name="mcp_servers_add_stdio">Stdio 명령어</string>
|
<string name="mcp_servers_add_stdio">Stdio 명령어</string>
|
||||||
@@ -834,4 +864,18 @@
|
|||||||
<string name="group_chat_settings_system_prompt_hint">이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)…</string>
|
<string name="group_chat_settings_system_prompt_hint">이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)…</string>
|
||||||
<string name="group_chat_settings_system_prompt_desc">이 방의 모든 봇에 적용되는 사용자 지정 가이드라인</string>
|
<string name="group_chat_settings_system_prompt_desc">이 방의 모든 봇에 적용되는 사용자 지정 가이드라인</string>
|
||||||
<string name="group_chat_action_settings">설정</string>
|
<string name="group_chat_action_settings">설정</string>
|
||||||
|
<string name="subagent_agent_plan">에이전트 계획</string>
|
||||||
|
<string name="subagent_live_transcript">실시간 기록</string>
|
||||||
|
<string name="subagent_no_active">활성 작업 또는 계획 항목이 없습니다.</string>
|
||||||
|
<string name="subagent_summary">요약: %1$s</string>
|
||||||
|
<string name="subagent_task_plan_inspection">작업 및 계획 검사</string>
|
||||||
|
<string name="subagent_action_steer">안내</string>
|
||||||
|
<string name="subagent_action_stop">중지</string>
|
||||||
|
<string name="subagent_steer_placeholder">하위 에이전트 안내 입력…</string>
|
||||||
|
<string name="subagent_status_running">실행 중</string>
|
||||||
|
<string name="subagent_status_completed">완료됨</string>
|
||||||
|
<string name="subagent_status_steered">안내됨</string>
|
||||||
|
<string name="subagent_status_cancelled">취소됨</string>
|
||||||
|
<string name="subagent_status_failed">실패</string>
|
||||||
|
<string name="subagent_duration">%1$.1f초</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -249,6 +249,7 @@
|
|||||||
<string name="settings_item_language">语言</string>
|
<string name="settings_item_language">语言</string>
|
||||||
<string name="language_system">跟随系统</string>
|
<string name="language_system">跟随系统</string>
|
||||||
<string name="language_english">English</string>
|
<string name="language_english">English</string>
|
||||||
|
<string name="language_arabic">العربية</string>
|
||||||
<string name="language_chinese">简体中文</string>
|
<string name="language_chinese">简体中文</string>
|
||||||
<string name="language_korean">한국어</string>
|
<string name="language_korean">한국어</string>
|
||||||
<string name="connect_selected_profile">配置:%1$s</string>
|
<string name="connect_selected_profile">配置:%1$s</string>
|
||||||
@@ -523,6 +524,12 @@
|
|||||||
<string name="sessions_action_rename">重命名</string>
|
<string name="sessions_action_rename">重命名</string>
|
||||||
<string name="sessions_action_pin">置顶</string>
|
<string name="sessions_action_pin">置顶</string>
|
||||||
<string name="sessions_action_unpin">取消置顶</string>
|
<string name="sessions_action_unpin">取消置顶</string>
|
||||||
|
<string name="sessions_action_hide">隐藏</string>
|
||||||
|
<string name="sessions_action_unhide">取消隐藏</string>
|
||||||
|
<string name="sessions_show_hidden">显示已隐藏的会话</string>
|
||||||
|
<string name="sessions_hide_hidden">隐藏已隐藏的会话</string>
|
||||||
|
<string name="sessions_hidden_badge">已隐藏</string>
|
||||||
|
<string name="sessions_all_hidden_desc">所有会话当前均已隐藏。点击下方以显示已隐藏的会话。</string>
|
||||||
<string name="sessions_pinned_indicator">已置顶</string>
|
<string name="sessions_pinned_indicator">已置顶</string>
|
||||||
<string name="sessions_fork_indicator">分支会话</string>
|
<string name="sessions_fork_indicator">分支会话</string>
|
||||||
<string name="sessions_rename_title">重命名会话</string>
|
<string name="sessions_rename_title">重命名会话</string>
|
||||||
@@ -596,6 +603,12 @@
|
|||||||
<string name="profiles_action_set_model">设置模型</string>
|
<string name="profiles_action_set_model">设置模型</string>
|
||||||
<string name="profiles_action_clone">克隆</string>
|
<string name="profiles_action_clone">克隆</string>
|
||||||
<string name="profiles_action_edit_description">编辑描述</string>
|
<string name="profiles_action_edit_description">编辑描述</string>
|
||||||
|
<string name="profiles_action_hide">隐藏配置文件</string>
|
||||||
|
<string name="profiles_action_unhide">取消隐藏配置文件</string>
|
||||||
|
<string name="profiles_show_hidden">显示隐藏的配置文件</string>
|
||||||
|
<string name="profiles_hide_hidden">隐藏已隐藏的配置文件</string>
|
||||||
|
<string name="profiles_hidden_badge">已隐藏</string>
|
||||||
|
<string name="profiles_all_hidden_desc">所有配置文件当前均已隐藏。点击下方以显示已隐藏的配置文件。</string>
|
||||||
<string name="profiles_content_desc_active">当前配置</string>
|
<string name="profiles_content_desc_active">当前配置</string>
|
||||||
<string name="profiles_title_edit_soul">编辑 Soul 配置:%1$s</string>
|
<string name="profiles_title_edit_soul">编辑 Soul 配置:%1$s</string>
|
||||||
<string name="profiles_title_set_model">设置模型:%1$s</string>
|
<string name="profiles_title_set_model">设置模型:%1$s</string>
|
||||||
@@ -708,6 +721,16 @@
|
|||||||
<string name="mcp_servers_label_transport">传输:%1$s</string>
|
<string name="mcp_servers_label_transport">传输:%1$s</string>
|
||||||
<string name="mcp_servers_label_command">命令:</string>
|
<string name="mcp_servers_label_command">命令:</string>
|
||||||
<string name="mcp_servers_action_test">测试</string>
|
<string name="mcp_servers_action_test">测试</string>
|
||||||
|
<string name="mcp_servers_action_test_all">测试全部</string>
|
||||||
|
<string name="mcp_servers_action_import_json">导入 JSON</string>
|
||||||
|
<string name="mcp_servers_import_dialog_title">导入 MCP 服务器</string>
|
||||||
|
<string name="mcp_servers_import_dialog_desc">粘贴 Claude Desktop / Cursor mcpServers JSON 片段或服务器定义。</string>
|
||||||
|
<string name="mcp_servers_import_dialog_paste">从剪贴板粘贴</string>
|
||||||
|
<string name="mcp_servers_import_dialog_importing">导入中…</string>
|
||||||
|
<string name="mcp_servers_status_healthy">正常</string>
|
||||||
|
<string name="mcp_servers_status_needs_auth">需要认证</string>
|
||||||
|
<string name="mcp_servers_status_error">错误</string>
|
||||||
|
<string name="mcp_servers_status_testing">测试中…</string>
|
||||||
<string name="mcp_servers_add_server">添加服务器</string>
|
<string name="mcp_servers_add_server">添加服务器</string>
|
||||||
<string name="mcp_servers_add_http">HTTP 地址</string>
|
<string name="mcp_servers_add_http">HTTP 地址</string>
|
||||||
<string name="mcp_servers_empty_title">没有 MCP 服务器</string>
|
<string name="mcp_servers_empty_title">没有 MCP 服务器</string>
|
||||||
@@ -1077,6 +1100,15 @@
|
|||||||
<string name="subagent_no_active">没有活动任务或计划项。</string>
|
<string name="subagent_no_active">没有活动任务或计划项。</string>
|
||||||
<string name="subagent_summary">摘要:%1$s</string>
|
<string name="subagent_summary">摘要:%1$s</string>
|
||||||
<string name="subagent_task_plan_inspection">任务与计划检查</string>
|
<string name="subagent_task_plan_inspection">任务与计划检查</string>
|
||||||
|
<string name="subagent_action_steer">引导</string>
|
||||||
|
<string name="subagent_action_stop">停止</string>
|
||||||
|
<string name="subagent_steer_placeholder">向子代理发送调整引导…</string>
|
||||||
|
<string name="subagent_status_running">运行中</string>
|
||||||
|
<string name="subagent_status_completed">已完成</string>
|
||||||
|
<string name="subagent_status_steered">已引导</string>
|
||||||
|
<string name="subagent_status_cancelled">已取消</string>
|
||||||
|
<string name="subagent_status_failed">失败</string>
|
||||||
|
<string name="subagent_duration">%1$.1f秒</string>
|
||||||
<string name="system_auth_required">需要认证</string>
|
<string name="system_auth_required">需要认证</string>
|
||||||
<string name="system_checkpoint_prune">清理检查点</string>
|
<string name="system_checkpoint_prune">清理检查点</string>
|
||||||
<string name="system_config_migrate">迁移配置</string>
|
<string name="system_config_migrate">迁移配置</string>
|
||||||
|
|||||||
@@ -276,6 +276,7 @@
|
|||||||
<string name="settings_item_language">Language</string>
|
<string name="settings_item_language">Language</string>
|
||||||
<string name="language_system">System</string>
|
<string name="language_system">System</string>
|
||||||
<string name="language_english">English</string>
|
<string name="language_english">English</string>
|
||||||
|
<string name="language_arabic">العربية</string>
|
||||||
<string name="language_chinese">简体中文</string>
|
<string name="language_chinese">简体中文</string>
|
||||||
<string name="language_korean">한국어</string>
|
<string name="language_korean">한국어</string>
|
||||||
|
|
||||||
@@ -599,6 +600,12 @@
|
|||||||
<string name="sessions_action_rename">Rename</string>
|
<string name="sessions_action_rename">Rename</string>
|
||||||
<string name="sessions_action_pin">Pin</string>
|
<string name="sessions_action_pin">Pin</string>
|
||||||
<string name="sessions_action_unpin">Unpin</string>
|
<string name="sessions_action_unpin">Unpin</string>
|
||||||
|
<string name="sessions_action_hide">Hide</string>
|
||||||
|
<string name="sessions_action_unhide">Unhide</string>
|
||||||
|
<string name="sessions_show_hidden">Show hidden sessions</string>
|
||||||
|
<string name="sessions_hide_hidden">Hide hidden sessions</string>
|
||||||
|
<string name="sessions_hidden_badge">Hidden</string>
|
||||||
|
<string name="sessions_all_hidden_desc">All sessions are currently hidden. Tap below to show hidden sessions.</string>
|
||||||
<string name="sessions_pinned_indicator">Pinned</string>
|
<string name="sessions_pinned_indicator">Pinned</string>
|
||||||
<string name="sessions_fork_indicator">Forked session</string>
|
<string name="sessions_fork_indicator">Forked session</string>
|
||||||
<string name="sessions_rename_title">Rename Session</string>
|
<string name="sessions_rename_title">Rename Session</string>
|
||||||
@@ -682,6 +689,12 @@
|
|||||||
<string name="profiles_action_set_model">Set Model</string>
|
<string name="profiles_action_set_model">Set Model</string>
|
||||||
<string name="profiles_action_clone">Clone</string>
|
<string name="profiles_action_clone">Clone</string>
|
||||||
<string name="profiles_action_edit_description">Edit Description</string>
|
<string name="profiles_action_edit_description">Edit Description</string>
|
||||||
|
<string name="profiles_action_hide">Hide Profile</string>
|
||||||
|
<string name="profiles_action_unhide">Unhide Profile</string>
|
||||||
|
<string name="profiles_show_hidden">Show hidden profiles</string>
|
||||||
|
<string name="profiles_hide_hidden">Hide hidden profiles</string>
|
||||||
|
<string name="profiles_hidden_badge">Hidden</string>
|
||||||
|
<string name="profiles_all_hidden_desc">All profiles are currently hidden. Tap below to show hidden profiles.</string>
|
||||||
<string name="profiles_content_desc_active">Active Profile</string>
|
<string name="profiles_content_desc_active">Active Profile</string>
|
||||||
<string name="profiles_title_edit_soul">Edit Soul Profile: %1$s</string>
|
<string name="profiles_title_edit_soul">Edit Soul Profile: %1$s</string>
|
||||||
<string name="profiles_title_set_model">Set Model: %1$s</string>
|
<string name="profiles_title_set_model">Set Model: %1$s</string>
|
||||||
@@ -800,6 +813,16 @@
|
|||||||
<string name="mcp_servers_label_transport">Transport: %1$s</string>
|
<string name="mcp_servers_label_transport">Transport: %1$s</string>
|
||||||
<string name="mcp_servers_label_command">Command:</string>
|
<string name="mcp_servers_label_command">Command:</string>
|
||||||
<string name="mcp_servers_action_test">Test</string>
|
<string name="mcp_servers_action_test">Test</string>
|
||||||
|
<string name="mcp_servers_action_test_all">Test All</string>
|
||||||
|
<string name="mcp_servers_action_import_json">Import JSON</string>
|
||||||
|
<string name="mcp_servers_import_dialog_title">Import MCP Servers</string>
|
||||||
|
<string name="mcp_servers_import_dialog_desc">Paste Claude Desktop / Cursor mcpServers JSON snippet or server definition.</string>
|
||||||
|
<string name="mcp_servers_import_dialog_paste">Paste Clipboard</string>
|
||||||
|
<string name="mcp_servers_import_dialog_importing">Importing…</string>
|
||||||
|
<string name="mcp_servers_status_healthy">Healthy</string>
|
||||||
|
<string name="mcp_servers_status_needs_auth">Needs Auth</string>
|
||||||
|
<string name="mcp_servers_status_error">Error</string>
|
||||||
|
<string name="mcp_servers_status_testing">Testing…</string>
|
||||||
<string name="mcp_servers_add_server">Add server</string>
|
<string name="mcp_servers_add_server">Add server</string>
|
||||||
<string name="mcp_servers_add_http">HTTP URL</string>
|
<string name="mcp_servers_add_http">HTTP URL</string>
|
||||||
<string name="mcp_servers_empty_title">No MCP servers</string>
|
<string name="mcp_servers_empty_title">No MCP servers</string>
|
||||||
@@ -1206,6 +1229,15 @@
|
|||||||
<string name="subagent_no_active">No active tasks or plan items.</string>
|
<string name="subagent_no_active">No active tasks or plan items.</string>
|
||||||
<string name="subagent_summary">Summary: %1$s</string>
|
<string name="subagent_summary">Summary: %1$s</string>
|
||||||
<string name="subagent_task_plan_inspection">Task & Plan Inspection</string>
|
<string name="subagent_task_plan_inspection">Task & Plan Inspection</string>
|
||||||
|
<string name="subagent_action_steer">Steer</string>
|
||||||
|
<string name="subagent_action_stop">Stop</string>
|
||||||
|
<string name="subagent_steer_placeholder">Course-correction guidance for subagent…</string>
|
||||||
|
<string name="subagent_status_running">Running</string>
|
||||||
|
<string name="subagent_status_completed">Completed</string>
|
||||||
|
<string name="subagent_status_steered">Steered</string>
|
||||||
|
<string name="subagent_status_cancelled">Cancelled</string>
|
||||||
|
<string name="subagent_status_failed">Failed</string>
|
||||||
|
<string name="subagent_duration">%1$.1fs</string>
|
||||||
<string name="system_auth_required">Auth required</string>
|
<string name="system_auth_required">Auth required</string>
|
||||||
<string name="system_checkpoint_prune">Checkpoint prune</string>
|
<string name="system_checkpoint_prune">Checkpoint prune</string>
|
||||||
<string name="system_config_migrate">Config migrate</string>
|
<string name="system_config_migrate">Config migrate</string>
|
||||||
|
|||||||
@@ -312,6 +312,41 @@ class ModelSerializationTest {
|
|||||||
assertEquals(false, s8.pinned)
|
assertEquals(false, s8.pinned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSessionInfoDeserialization_hiddenToleratesIntAndBooleanAndString() {
|
||||||
|
val s1 = json.decodeFromString<SessionInfo>("""{"id": "s1", "hidden": 0}""")
|
||||||
|
assertEquals(false, s1.hidden)
|
||||||
|
|
||||||
|
val s2 = json.decodeFromString<SessionInfo>("""{"id": "s2", "hidden": 1}""")
|
||||||
|
assertEquals(true, s2.hidden)
|
||||||
|
|
||||||
|
val s3 = json.decodeFromString<SessionInfo>("""{"id": "s3", "hidden": false}""")
|
||||||
|
assertEquals(false, s3.hidden)
|
||||||
|
|
||||||
|
val s4 = json.decodeFromString<SessionInfo>("""{"id": "s4", "hidden": true}""")
|
||||||
|
assertEquals(true, s4.hidden)
|
||||||
|
|
||||||
|
val s5 = json.decodeFromString<SessionInfo>("""{"id": "s5", "hidden": null}""")
|
||||||
|
assertNull(s5.hidden)
|
||||||
|
|
||||||
|
val s6 = json.decodeFromString<SessionInfo>("""{"id": "s6"}""")
|
||||||
|
assertNull(s6.hidden)
|
||||||
|
|
||||||
|
val s7 = json.decodeFromString<SessionInfo>("""{"id": "s7", "hidden": "1"}""")
|
||||||
|
assertEquals(true, s7.hidden)
|
||||||
|
|
||||||
|
val s8 = json.decodeFromString<SessionInfo>("""{"id": "s8", "hidden": "false"}""")
|
||||||
|
assertEquals(false, s8.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSessionRenameRequestSerialization_includesHidden() {
|
||||||
|
val req = SessionRenameRequest(title = "new name", hidden = true)
|
||||||
|
val encoded = json.encodeToString(SessionRenameRequest.serializer(), req)
|
||||||
|
assertTrue(encoded.contains("\"hidden\":true"))
|
||||||
|
assertTrue(encoded.contains("\"title\":\"new name\""))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
|
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
|
||||||
val jsonStr = "{}"
|
val jsonStr = "{}"
|
||||||
|
|||||||
@@ -118,6 +118,19 @@ class ProfileScopeInterceptorTest {
|
|||||||
assertEquals("alpha", url.queryParameter("profile"))
|
assertEquals("alpha", url.queryParameter("profile"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun memoryEndpoints_areScoped() {
|
||||||
|
val client = clientFor("yasmin")
|
||||||
|
for (path in listOf("api/memory", "api/memory/learning-graph", "api/memory/providers/mem0/config")) {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
|
||||||
|
val req = Request.Builder().url(server.url(path)).build()
|
||||||
|
client.newCall(req).execute().close()
|
||||||
|
val url = server.takeRequest().requestUrl!!
|
||||||
|
assertEquals("yasmin", url.queryParameter("profile"))
|
||||||
|
assertEquals("/$path", url.encodedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun lookalikePath_notScoped() {
|
fun lookalikePath_notScoped() {
|
||||||
// Sourcery review (PR #540): `startsWith` must not match non-segment
|
// Sourcery review (PR #540): `startsWith` must not match non-segment
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import io.mockk.unmockkAll
|
|||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -668,4 +669,81 @@ class EventParserTest {
|
|||||||
assertEquals(3, (usage?.get("compressions") as? Number)?.toInt())
|
assertEquals(3, (usage?.get("compressions") as? Number)?.toInt())
|
||||||
assertEquals(12500, (usage?.get("context_used") as? Number)?.toInt())
|
assertEquals(12500, (usage?.get("context_used") as? Number)?.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseParams_parsesBareEventMap() {
|
||||||
|
val params =
|
||||||
|
mapOf(
|
||||||
|
"type" to "message.token",
|
||||||
|
"session_id" to "sess-replay-1",
|
||||||
|
"seq" to 42,
|
||||||
|
"payload" to mapOf("text" to "hello replay"),
|
||||||
|
)
|
||||||
|
val event = EventParser.parseParams(params)
|
||||||
|
assertTrue(event is WsEvent.MessageToken)
|
||||||
|
val tokenEvent = event as WsEvent.MessageToken
|
||||||
|
assertEquals("hello replay", tokenEvent.token)
|
||||||
|
assertEquals("sess-replay-1", tokenEvent.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseParams_parsesToolEvent() {
|
||||||
|
val params =
|
||||||
|
mapOf(
|
||||||
|
"type" to "tool.start",
|
||||||
|
"session_id" to "sess-replay-2",
|
||||||
|
"seq" to 43,
|
||||||
|
"payload" to mapOf("name" to "terminal", "tool_id" to "t1"),
|
||||||
|
)
|
||||||
|
val event = EventParser.parseParams(params)
|
||||||
|
assertTrue(event is WsEvent.ToolStart)
|
||||||
|
val toolEvent = event as WsEvent.ToolStart
|
||||||
|
assertEquals("terminal", toolEvent.name)
|
||||||
|
assertEquals("sess-replay-2", toolEvent.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseTodoUpdated_returnsTodoUpdatedEvent() {
|
||||||
|
val response =
|
||||||
|
createJsonRpcResponse(
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id = null,
|
||||||
|
result = null,
|
||||||
|
error = null,
|
||||||
|
method = "event",
|
||||||
|
params =
|
||||||
|
mapOf(
|
||||||
|
"type" to "todo.updated",
|
||||||
|
"session_id" to "sess-abc",
|
||||||
|
"payload" to
|
||||||
|
mapOf(
|
||||||
|
"revision" to 5,
|
||||||
|
"todos" to
|
||||||
|
listOf(
|
||||||
|
mapOf("id" to "1", "content" to "Parent task", "status" to "in_progress"),
|
||||||
|
mapOf(
|
||||||
|
"id" to "2",
|
||||||
|
"content" to "Sub task",
|
||||||
|
"status" to "pending",
|
||||||
|
"parent" to "1",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val event = EventParser.parse(response)
|
||||||
|
assertTrue(event is WsEvent.TodoUpdated)
|
||||||
|
val todoUpdated = event as WsEvent.TodoUpdated
|
||||||
|
assertEquals("sess-abc", todoUpdated.sessionId)
|
||||||
|
assertEquals(5, todoUpdated.revision)
|
||||||
|
assertEquals(2, todoUpdated.todos.size)
|
||||||
|
assertEquals("1", todoUpdated.todos[0].id)
|
||||||
|
assertEquals("Parent task", todoUpdated.todos[0].content)
|
||||||
|
assertEquals(null, todoUpdated.todos[0].parent)
|
||||||
|
assertFalse(todoUpdated.todos[0].isSubtask)
|
||||||
|
assertEquals("2", todoUpdated.todos[1].id)
|
||||||
|
assertEquals("Sub task", todoUpdated.todos[1].content)
|
||||||
|
assertEquals("1", todoUpdated.todos[1].parent)
|
||||||
|
assertTrue(todoUpdated.todos[1].isSubtask)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1527,4 +1527,226 @@ class HermesWsClientTest {
|
|||||||
HermesWsClient.connectionStatus.value != ConnectionStatus.CONNECTED,
|
HermesWsClient.connectionStatus.value != ConnectionStatus.CONNECTED,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSequenceNumberDeduplication() {
|
||||||
|
var serverWebSocket: WebSocket? = null
|
||||||
|
val serverLatch = CountDownLatch(1)
|
||||||
|
mockWebServer.enqueue(
|
||||||
|
MockResponse().withWebSocketUpgrade(
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: okhttp3.Response,
|
||||||
|
) {
|
||||||
|
serverWebSocket = webSocket
|
||||||
|
serverLatch.countDown()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
HermesWsClient.connect()
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(5000) {
|
||||||
|
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(serverLatch.await(5, TimeUnit.SECONDS))
|
||||||
|
val ws = serverWebSocket
|
||||||
|
assertNotNull(ws)
|
||||||
|
|
||||||
|
val receivedTokens = mutableListOf<String>()
|
||||||
|
val collectorJob =
|
||||||
|
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
|
||||||
|
HermesWsClient.events.collect { event ->
|
||||||
|
if (event is WsEvent.MessageToken) {
|
||||||
|
receivedTokens.add(event.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send seq 1
|
||||||
|
ws!!.send(
|
||||||
|
"""{"method":"event","params":{"type":"message.token","session_id":"s1","seq":1,"payload":{"text":"A"}}}""",
|
||||||
|
)
|
||||||
|
Thread.sleep(100)
|
||||||
|
assertEquals(1, HermesWsClient.getSeqWatermarks()["s1"])
|
||||||
|
assertEquals(listOf("A"), receivedTokens)
|
||||||
|
|
||||||
|
// Send duplicate seq 1 -> should be dropped
|
||||||
|
ws.send(
|
||||||
|
"""{"method":"event","params":{"type":"message.token","session_id":"s1","seq":1,"payload":{"text":"A-dup"}}}""",
|
||||||
|
)
|
||||||
|
Thread.sleep(100)
|
||||||
|
assertEquals(1, HermesWsClient.getSeqWatermarks()["s1"])
|
||||||
|
assertEquals(listOf("A"), receivedTokens)
|
||||||
|
|
||||||
|
// Send seq 2 -> should be accepted
|
||||||
|
ws.send(
|
||||||
|
"""{"method":"event","params":{"type":"message.token","session_id":"s1","seq":2,"payload":{"text":"B"}}}""",
|
||||||
|
)
|
||||||
|
Thread.sleep(100)
|
||||||
|
assertEquals(2, HermesWsClient.getSeqWatermarks()["s1"])
|
||||||
|
assertEquals(listOf("A", "B"), receivedTokens)
|
||||||
|
|
||||||
|
collectorJob.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testEpochChangeClearsWatermarks() {
|
||||||
|
var serverWebSocket: WebSocket? = null
|
||||||
|
val serverLatch = CountDownLatch(1)
|
||||||
|
mockWebServer.enqueue(
|
||||||
|
MockResponse().withWebSocketUpgrade(
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: okhttp3.Response,
|
||||||
|
) {
|
||||||
|
serverWebSocket = webSocket
|
||||||
|
serverLatch.countDown()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
HermesWsClient.connect()
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(5000) {
|
||||||
|
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(serverLatch.await(5, TimeUnit.SECONDS))
|
||||||
|
val ws = serverWebSocket
|
||||||
|
assertNotNull(ws)
|
||||||
|
|
||||||
|
// Initial gateway.ready with epoch-1
|
||||||
|
ws!!.send(
|
||||||
|
"""{"method":"event","params":{"type":"gateway.ready","payload":{"replay_epoch":"epoch-1"}}}""",
|
||||||
|
)
|
||||||
|
Thread.sleep(100)
|
||||||
|
HermesWsClient.setSeqWatermark("s1", 10)
|
||||||
|
assertEquals(10, HermesWsClient.getSeqWatermarks()["s1"])
|
||||||
|
|
||||||
|
// Second gateway.ready with new epoch-2 -> backend restarted, watermarks cleared
|
||||||
|
ws.send(
|
||||||
|
"""{"method":"event","params":{"type":"gateway.ready","payload":{"replay_epoch":"epoch-2"}}}""",
|
||||||
|
)
|
||||||
|
Thread.sleep(100)
|
||||||
|
assertTrue(HermesWsClient.getSeqWatermarks().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testReplayOnReconnectFlow() {
|
||||||
|
var serverWebSocket: WebSocket? = null
|
||||||
|
val serverLatch = CountDownLatch(1)
|
||||||
|
val requestLatch = CountDownLatch(1)
|
||||||
|
var receivedMethod: String? = null
|
||||||
|
|
||||||
|
mockWebServer.enqueue(
|
||||||
|
MockResponse().withWebSocketUpgrade(
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: okhttp3.Response,
|
||||||
|
) {
|
||||||
|
serverWebSocket = webSocket
|
||||||
|
serverLatch.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
if (text.contains(WsMethods.SESSION_EVENTS_SINCE)) {
|
||||||
|
receivedMethod = WsMethods.SESSION_EVENTS_SINCE
|
||||||
|
// Extract ID from JSON-RPC request
|
||||||
|
val id = Regex(""""id":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: "1"
|
||||||
|
webSocket.send(
|
||||||
|
"""{"jsonrpc":"2.0","id":"$id","result":{"epoch":"ep1","events":[{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"replayed"}}]}}""",
|
||||||
|
)
|
||||||
|
requestLatch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
HermesWsClient.setSeqWatermark("s1", 5)
|
||||||
|
HermesWsClient.connect()
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(5000) {
|
||||||
|
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(serverLatch.await(5, TimeUnit.SECONDS))
|
||||||
|
assertTrue(requestLatch.await(5, TimeUnit.SECONDS))
|
||||||
|
assertEquals(WsMethods.SESSION_EVENTS_SINCE, receivedMethod)
|
||||||
|
|
||||||
|
// Wait for replay processing
|
||||||
|
Thread.sleep(200)
|
||||||
|
assertEquals(6, HermesWsClient.getSeqWatermarks()["s1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testPingMethodConstant() {
|
||||||
|
assertEquals("ping", WsMethods.PING)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testPingMeasuresLatencyAndUpdatesTimestamp() {
|
||||||
|
var serverWebSocket: WebSocket? = null
|
||||||
|
val serverLatch = CountDownLatch(1)
|
||||||
|
val pingLatch = CountDownLatch(1)
|
||||||
|
var pingReceived = false
|
||||||
|
|
||||||
|
mockWebServer.enqueue(
|
||||||
|
MockResponse().withWebSocketUpgrade(
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: okhttp3.Response,
|
||||||
|
) {
|
||||||
|
serverWebSocket = webSocket
|
||||||
|
serverLatch.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
if (text.contains(""""method":"ping"""")) {
|
||||||
|
pingReceived = true
|
||||||
|
val id = Regex(""""id":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: "1"
|
||||||
|
webSocket.send(
|
||||||
|
"""{"jsonrpc":"2.0","id":"$id","result":{"pong":true,"timestamp":1700000000.0}}""",
|
||||||
|
)
|
||||||
|
pingLatch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
HermesWsClient.connect()
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(5000) {
|
||||||
|
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(serverLatch.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
val latency =
|
||||||
|
runBlocking {
|
||||||
|
HermesWsClient.ping(timeoutMs = 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(pingLatch.await(5, TimeUnit.SECONDS))
|
||||||
|
assertTrue(pingReceived)
|
||||||
|
assertTrue(latency >= 0)
|
||||||
|
assertNotNull(HermesWsClient.lastLatencyMs.value)
|
||||||
|
assertEquals(latency, HermesWsClient.lastLatencyMs.value)
|
||||||
|
assertTrue(HermesWsClient.lastPongTimestamp > 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.bots
|
package com.m57.hermescontrol.ui.bots
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
||||||
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
||||||
import com.m57.hermescontrol.data.model.BotRosterMeta
|
import com.m57.hermescontrol.data.model.BotRosterMeta
|
||||||
@@ -45,6 +46,8 @@ class BotsViewModelTest {
|
|||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
mockkObject(AuthManager)
|
||||||
|
every { AuthManager.getHiddenProfiles() } returns emptyList()
|
||||||
mockkObject(ApiClient)
|
mockkObject(ApiClient)
|
||||||
mockApi = mockk(relaxed = true)
|
mockApi = mockk(relaxed = true)
|
||||||
every { ApiClient.hermesApi } returns mockApi
|
every { ApiClient.hermesApi } returns mockApi
|
||||||
@@ -57,6 +60,7 @@ class BotsViewModelTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
unmockkObject(AuthManager)
|
||||||
unmockkObject(ApiClient)
|
unmockkObject(ApiClient)
|
||||||
unmockkObject(HermesWsClient)
|
unmockkObject(HermesWsClient)
|
||||||
Dispatchers.resetMain()
|
Dispatchers.resetMain()
|
||||||
@@ -139,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,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,7 +369,8 @@ 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
|
||||||
|
.reduce(
|
||||||
s,
|
s,
|
||||||
stream,
|
stream,
|
||||||
WsEvent.ReasoningDelta("thinking about meow", "session-1"),
|
WsEvent.ReasoningDelta("thinking about meow", "session-1"),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -478,6 +478,40 @@ class ChatViewModelTest {
|
|||||||
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSlashCommand_btw_dispatchesPromptBtw_andSetsBtwState() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
|
||||||
|
every {
|
||||||
|
HermesWsClient.request(WsMethods.PROMPT_BTW, any(), any())
|
||||||
|
} returns
|
||||||
|
CompletableDeferred(
|
||||||
|
mapOf("task_id" to "btw_123456"),
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.sendMessage("/btw which file was that in?")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val btw = viewModel.uiState.value.btwState
|
||||||
|
assertNotNull(btw)
|
||||||
|
assertEquals("which file was that in?", btw?.question)
|
||||||
|
assertEquals("btw_123456", btw?.taskId)
|
||||||
|
assertTrue(btw?.isLoading == true)
|
||||||
|
verify {
|
||||||
|
HermesWsClient.request(
|
||||||
|
WsMethods.PROMPT_BTW,
|
||||||
|
mapOf("session_id" to sessionId, "text" to "which file was that in?"),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Ensure transcript messages were NOT polluted with the side question
|
||||||
|
assertTrue(
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.none { it.content.contains("which file was that in?") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Slash usage ranking (issue #865) ────────────────────────────────────
|
// ── Slash usage ranking (issue #865) ────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -513,7 +547,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 +1070,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 +1081,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 +1093,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 +1617,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 +1668,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 +1687,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 +1745,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 +1978,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 +2008,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 +2361,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 +2385,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 +2437,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 +3360,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 +3508,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,7 +3573,8 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
mockkObject(GatewayFileClient)
|
mockkObject(GatewayFileClient)
|
||||||
val cacheDir =
|
val cacheDir =
|
||||||
java.io.File(
|
java.io
|
||||||
|
.File(
|
||||||
System.getProperty("java.io.tmpdir"),
|
System.getProperty("java.io.tmpdir"),
|
||||||
"gw_save_${System.nanoTime()}",
|
"gw_save_${System.nanoTime()}",
|
||||||
).apply { mkdirs() }
|
).apply { mkdirs() }
|
||||||
@@ -3514,7 +3582,8 @@ class ChatViewModelTest {
|
|||||||
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,7 +3621,8 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
mockkObject(GatewayFileClient)
|
mockkObject(GatewayFileClient)
|
||||||
val cacheDir =
|
val cacheDir =
|
||||||
java.io.File(
|
java.io
|
||||||
|
.File(
|
||||||
System.getProperty("java.io.tmpdir"),
|
System.getProperty("java.io.tmpdir"),
|
||||||
"gw_save_${System.nanoTime()}",
|
"gw_save_${System.nanoTime()}",
|
||||||
).apply { mkdirs() }
|
).apply { mkdirs() }
|
||||||
@@ -3590,7 +3660,8 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
mockkObject(GatewayFileClient)
|
mockkObject(GatewayFileClient)
|
||||||
val cacheDir =
|
val cacheDir =
|
||||||
java.io.File(
|
java.io
|
||||||
|
.File(
|
||||||
System.getProperty("java.io.tmpdir"),
|
System.getProperty("java.io.tmpdir"),
|
||||||
"gw_save_${System.nanoTime()}",
|
"gw_save_${System.nanoTime()}",
|
||||||
).apply { mkdirs() }
|
).apply { mkdirs() }
|
||||||
@@ -3606,7 +3677,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 +3697,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,7 +3709,8 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
mockkObject(GatewayFileClient)
|
mockkObject(GatewayFileClient)
|
||||||
val cacheDir =
|
val cacheDir =
|
||||||
java.io.File(
|
java.io
|
||||||
|
.File(
|
||||||
System.getProperty("java.io.tmpdir"),
|
System.getProperty("java.io.tmpdir"),
|
||||||
"gw_save_${System.nanoTime()}",
|
"gw_save_${System.nanoTime()}",
|
||||||
).apply { mkdirs() }
|
).apply { mkdirs() }
|
||||||
@@ -3675,7 +3752,8 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
mockkObject(GatewayFileClient)
|
mockkObject(GatewayFileClient)
|
||||||
val cacheDir =
|
val cacheDir =
|
||||||
java.io.File(
|
java.io
|
||||||
|
.File(
|
||||||
System.getProperty("java.io.tmpdir"),
|
System.getProperty("java.io.tmpdir"),
|
||||||
"gw_open_${System.nanoTime()}",
|
"gw_open_${System.nanoTime()}",
|
||||||
).apply { mkdirs() }
|
).apply { mkdirs() }
|
||||||
@@ -3711,7 +3789,8 @@ class ChatViewModelTest {
|
|||||||
runTest {
|
runTest {
|
||||||
mockkObject(GatewayFileClient)
|
mockkObject(GatewayFileClient)
|
||||||
val cacheDir =
|
val cacheDir =
|
||||||
java.io.File(
|
java.io
|
||||||
|
.File(
|
||||||
System.getProperty("java.io.tmpdir"),
|
System.getProperty("java.io.tmpdir"),
|
||||||
"gw_open_${System.nanoTime()}",
|
"gw_open_${System.nanoTime()}",
|
||||||
).apply { mkdirs() }
|
).apply { mkdirs() }
|
||||||
@@ -3743,7 +3822,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 +3846,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 +3867,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 +3895,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 +3934,73 @@ 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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subagent steering & cancellation (issue #1030) ───────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSteerSubagent_sendsRedirectAndUpdatesStatus() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
val indicator =
|
||||||
|
SubagentIndicator(
|
||||||
|
type = "subagent.start",
|
||||||
|
subagentId = "sub-123",
|
||||||
|
goal = "Search database",
|
||||||
|
status = "running",
|
||||||
|
)
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.SubagentEvent(
|
||||||
|
type = "subagent.start",
|
||||||
|
payload = mapOf("subagent_id" to "sub-123", "goal" to "Search database"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.steerSubagent(indicator, "Use index scan instead")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val updated =
|
||||||
|
viewModel.uiState.value.subagentIndicators
|
||||||
|
.first { it.subagentId == "sub-123" }
|
||||||
|
assertEquals("steered", updated.status)
|
||||||
|
assertTrue(updated.logs.any { it.text.contains("Use index scan instead") })
|
||||||
|
io.mockk.verify { HermesWsClient.sendRedirect(sessionId, "/steer sub-123 Use index scan instead", any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStopSubagent_sendsRedirectAndMarksCancelled() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
val indicator =
|
||||||
|
SubagentIndicator(
|
||||||
|
type = "subagent.start",
|
||||||
|
subagentId = "sub-123",
|
||||||
|
goal = "Run deep scan",
|
||||||
|
status = "running",
|
||||||
|
)
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.SubagentEvent(
|
||||||
|
type = "subagent.start",
|
||||||
|
payload = mapOf("subagent_id" to "sub-123", "goal" to "Run deep scan"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.stopSubagent(indicator)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val updated =
|
||||||
|
viewModel.uiState.value.subagentIndicators
|
||||||
|
.first { it.subagentId == "sub-123" }
|
||||||
|
assertEquals("cancelled", updated.status)
|
||||||
|
assertTrue(updated.logs.any { it.text.contains("Stopped subagent") })
|
||||||
|
io.mockk.verify { HermesWsClient.sendRedirect(sessionId, "/stop sub-123", any()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -530,6 +535,43 @@ class ChatWsEventReducerTest {
|
|||||||
assertEquals("💾 Self-improvement review: Skill 'android-ci' patched", msg.content)
|
assertEquals("💾 Self-improvement review: Skill 'android-ci' patched", msg.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBtwComplete_updatesBtwState() {
|
||||||
|
val state =
|
||||||
|
ChatUiState(
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
btwState =
|
||||||
|
BtwUiState(
|
||||||
|
question = "what model?",
|
||||||
|
isLoading = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val event =
|
||||||
|
WsEvent.BtwComplete(
|
||||||
|
taskId = "btw_123456",
|
||||||
|
question = "what model?",
|
||||||
|
text = "This conversation uses Gemini 3.7.",
|
||||||
|
sessionId = "session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state = state,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
event = event,
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
val btw = result.state.btwState
|
||||||
|
org.junit.Assert.assertNotNull(btw)
|
||||||
|
assertEquals("btw_123456", btw?.taskId)
|
||||||
|
assertEquals("what model?", btw?.question)
|
||||||
|
assertEquals("This conversation uses Gemini 3.7.", btw?.answer)
|
||||||
|
assertEquals(false, btw?.isLoading)
|
||||||
|
// Zero messages appended to transcript!
|
||||||
|
assertEquals(0, result.state.messages.size)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Issue #771: reasoning survives a tool call (regression) ────────────
|
// ── Issue #771: reasoning survives a tool call (regression) ────────────
|
||||||
//
|
//
|
||||||
// Real gateway capture for `run echo hi` (reasoning model):
|
// Real gateway capture for `run echo hi` (reasoning model):
|
||||||
@@ -556,7 +598,8 @@ class ChatWsEventReducerTest {
|
|||||||
var reasoningState = start.streamingState
|
var reasoningState = start.streamingState
|
||||||
for (token in reasoningTokens) {
|
for (token in reasoningTokens) {
|
||||||
reasoningState =
|
reasoningState =
|
||||||
ChatWsEventReducer.reduce(
|
ChatWsEventReducer
|
||||||
|
.reduce(
|
||||||
state,
|
state,
|
||||||
reasoningState,
|
reasoningState,
|
||||||
WsEvent.ReasoningDelta(token, "session-1"),
|
WsEvent.ReasoningDelta(token, "session-1"),
|
||||||
@@ -578,8 +621,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 +645,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 +741,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
|
||||||
@@ -916,4 +979,88 @@ class ChatWsEventReducerTest {
|
|||||||
assertEquals(1, result.state.compressionCount)
|
assertEquals(1, result.state.compressionCount)
|
||||||
assertEquals(null, result.state.usedContextTokens)
|
assertEquals(null, result.state.usedContextTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTodoUpdated_updatesStateTodos() {
|
||||||
|
val state = ChatUiState(currentSessionId = "session-1")
|
||||||
|
val todos =
|
||||||
|
listOf(
|
||||||
|
TodoItem(id = "1", content = "Main task", status = "in_progress"),
|
||||||
|
TodoItem(id = "2", content = "Subtask 1", status = "completed", parent = "1"),
|
||||||
|
TodoItem(id = "3", content = "Subtask 2", status = "pending", parent = "1"),
|
||||||
|
)
|
||||||
|
val event = WsEvent.TodoUpdated(todos = todos, revision = 1, sessionId = "session-1")
|
||||||
|
|
||||||
|
val result =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state = state,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
event = event,
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(3, result.state.todos.size)
|
||||||
|
assertEquals("Main task", result.state.todos[0].content)
|
||||||
|
assertEquals(null, result.state.todos[0].parent)
|
||||||
|
assertEquals("1", result.state.todos[1].parent)
|
||||||
|
assertTrue(result.state.todos[1].isCompleted)
|
||||||
|
assertTrue(result.state.todos[1].isSubtask)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTodoUpdated_differentSession_isIgnored() {
|
||||||
|
val state =
|
||||||
|
ChatUiState(
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
todos = listOf(TodoItem(id = "orig", content = "Original", status = "pending")),
|
||||||
|
)
|
||||||
|
val event =
|
||||||
|
WsEvent.TodoUpdated(
|
||||||
|
todos = listOf(TodoItem(id = "new", content = "New", status = "in_progress")),
|
||||||
|
revision = 1,
|
||||||
|
sessionId = "session-other",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state = state,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
event = event,
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, result.state.todos.size)
|
||||||
|
assertEquals("Original", result.state.todos[0].content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testExtractTodosFromMap_preservesParentHierarchy() {
|
||||||
|
val payload =
|
||||||
|
mapOf(
|
||||||
|
"todos" to
|
||||||
|
listOf(
|
||||||
|
mapOf("id" to "t1", "content" to "Root", "status" to "in_progress"),
|
||||||
|
mapOf("id" to "t2", "content" to "Child", "status" to "pending", "parent" to "t1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val extracted = extractTodosFromMap(payload)
|
||||||
|
assertEquals(2, extracted?.size)
|
||||||
|
assertEquals("t1", extracted?.get(0)?.id)
|
||||||
|
assertEquals(null, extracted?.get(0)?.parent)
|
||||||
|
assertEquals("t2", extracted?.get(1)?.id)
|
||||||
|
assertEquals("t1", extracted?.get(1)?.parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testExtractTodosFromJson_preservesParentHierarchy() {
|
||||||
|
val json =
|
||||||
|
"""{"todos":[{"id":"a","content":"Task A","status":"done"},""" +
|
||||||
|
"""{"id":"b","content":"Task B","status":"pending","parent":"a"}]}"""
|
||||||
|
val extracted = extractTodosFromJson(json)
|
||||||
|
assertEquals(2, extracted?.size)
|
||||||
|
assertEquals("a", extracted?.get(0)?.id)
|
||||||
|
assertEquals(null, extracted?.get(0)?.parent)
|
||||||
|
assertEquals("b", extracted?.get(1)?.id)
|
||||||
|
assertEquals("a", extracted?.get(1)?.parent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,14 @@ class SlashCommandDispatcherTest {
|
|||||||
assertEquals(SlashResult.OpenHistory, dispatcher.dispatch("/History"))
|
assertEquals(SlashResult.OpenHistory, dispatcher.dispatch("/History"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `btw routes to SideQuestion`() {
|
||||||
|
assertEquals(SlashResult.SideQuestion("what model is this?"), dispatcher.dispatch("/btw what model is this?"))
|
||||||
|
assertEquals(SlashResult.SideQuestion("what model is this?"), dispatcher.dispatch("/BTW what model is this?"))
|
||||||
|
assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw"))
|
||||||
|
assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw "))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `NEW uppercase still routes to NewSession`() {
|
fun `NEW uppercase still routes to NewSession`() {
|
||||||
// Dispatcher lower-cases before matching, so case must not matter.
|
// Dispatcher lower-cases before matching, so case must not matter.
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ResolveInfo
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkConstructor
|
||||||
|
import io.mockk.unmockkConstructor
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SpeechInputHelperTest {
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
mockkConstructor(Intent::class)
|
||||||
|
every { anyConstructed<Intent>().setAction(any()) } answers { self as Intent }
|
||||||
|
every { anyConstructed<Intent>().putExtra(any<String>(), any<String>()) } answers { self as Intent }
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkConstructor(Intent::class)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isSpeechInputAvailable_returnsTrue_whenPackageManagerFindsActivities() {
|
||||||
|
val context = mockk<Context>()
|
||||||
|
val packageManager = mockk<PackageManager>()
|
||||||
|
val resolveInfo = mockk<ResolveInfo>()
|
||||||
|
|
||||||
|
every { context.packageManager } returns packageManager
|
||||||
|
every {
|
||||||
|
packageManager.queryIntentActivities(any<Intent>(), any<Int>())
|
||||||
|
} returns listOf(resolveInfo)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
SpeechInputHelper.isSpeechInputAvailable(
|
||||||
|
context = context,
|
||||||
|
isRecognizerServiceAvailable = { false },
|
||||||
|
)
|
||||||
|
assertTrue(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isSpeechInputAvailable_returnsTrue_whenRecognitionServiceAvailableEvenIfNoActivities() {
|
||||||
|
val context = mockk<Context>()
|
||||||
|
val packageManager = mockk<PackageManager>()
|
||||||
|
|
||||||
|
every { context.packageManager } returns packageManager
|
||||||
|
every {
|
||||||
|
packageManager.queryIntentActivities(any<Intent>(), any<Int>())
|
||||||
|
} returns emptyList()
|
||||||
|
|
||||||
|
val result =
|
||||||
|
SpeechInputHelper.isSpeechInputAvailable(
|
||||||
|
context = context,
|
||||||
|
isRecognizerServiceAvailable = { true },
|
||||||
|
)
|
||||||
|
assertTrue(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isSpeechInputAvailable_returnsFalse_whenNoActivitiesAndNoRecognitionService() {
|
||||||
|
val context = mockk<Context>()
|
||||||
|
val packageManager = mockk<PackageManager>()
|
||||||
|
|
||||||
|
every { context.packageManager } returns packageManager
|
||||||
|
every {
|
||||||
|
packageManager.queryIntentActivities(any<Intent>(), any<Int>())
|
||||||
|
} returns emptyList()
|
||||||
|
|
||||||
|
val result =
|
||||||
|
SpeechInputHelper.isSpeechInputAvailable(
|
||||||
|
context = context,
|
||||||
|
isRecognizerServiceAvailable = { false },
|
||||||
|
)
|
||||||
|
assertFalse(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createSpeechIntent_constructsIntentWithActionAndPrompt() {
|
||||||
|
val prompt = "Listening..."
|
||||||
|
val intent = SpeechInputHelper.createSpeechIntent(prompt)
|
||||||
|
assertNotNull(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.buildTodoTree
|
||||||
import com.m57.hermescontrol.ui.chat.components.computeChipDisplay
|
import com.m57.hermescontrol.ui.chat.components.computeChipDisplay
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.flattenTodoTree
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.getAllDescendantTodos
|
||||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -142,4 +145,72 @@ class TaskProgressChipTest {
|
|||||||
)
|
)
|
||||||
assertTrue(hydrateTodosFromMessages(messages).isEmpty())
|
assertTrue(hydrateTodosFromMessages(messages).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildTodoTree constructs correct multi-level hierarchy`() {
|
||||||
|
val todos =
|
||||||
|
listOf(
|
||||||
|
TodoItem(id = "1", content = "Root 1", status = "in_progress"),
|
||||||
|
TodoItem(id = "1.1", content = "Child 1.1", status = "completed", parent = "1"),
|
||||||
|
TodoItem(id = "1.2", content = "Child 1.2", status = "pending", parent = "1"),
|
||||||
|
TodoItem(id = "1.2.1", content = "Grandchild 1.2.1", status = "pending", parent = "1.2"),
|
||||||
|
TodoItem(id = "2", content = "Root 2", status = "pending"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val tree = buildTodoTree(todos)
|
||||||
|
assertEquals(2, tree.size)
|
||||||
|
assertEquals("1", tree[0].item.id)
|
||||||
|
assertEquals(0, tree[0].depth)
|
||||||
|
assertEquals(2, tree[0].children.size)
|
||||||
|
assertEquals("1.1", tree[0].children[0].item.id)
|
||||||
|
assertEquals(1, tree[0].children[0].depth)
|
||||||
|
assertEquals("1.2", tree[0].children[1].item.id)
|
||||||
|
assertEquals(1, tree[0].children[1].depth)
|
||||||
|
assertEquals(1, tree[0].children[1].children.size)
|
||||||
|
assertEquals(
|
||||||
|
"1.2.1",
|
||||||
|
tree[0]
|
||||||
|
.children[1]
|
||||||
|
.children[0]
|
||||||
|
.item.id,
|
||||||
|
)
|
||||||
|
assertEquals(2, tree[0].children[1].children[0].depth)
|
||||||
|
assertEquals("2", tree[1].item.id)
|
||||||
|
assertEquals(0, tree[1].depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `flattenTodoTree collapses and expands subtasks correctly`() {
|
||||||
|
val todos =
|
||||||
|
listOf(
|
||||||
|
TodoItem(id = "1", content = "Root 1", status = "in_progress"),
|
||||||
|
TodoItem(id = "1.1", content = "Child 1.1", status = "completed", parent = "1"),
|
||||||
|
TodoItem(id = "1.2", content = "Child 1.2", status = "pending", parent = "1"),
|
||||||
|
TodoItem(id = "2", content = "Root 2", status = "pending"),
|
||||||
|
)
|
||||||
|
val tree = buildTodoTree(todos)
|
||||||
|
|
||||||
|
// Expanded state
|
||||||
|
val expandedRows = flattenTodoTree(tree, collapsedParentIds = emptySet())
|
||||||
|
assertEquals(4, expandedRows.size)
|
||||||
|
assertEquals("1", expandedRows[0].todo.id)
|
||||||
|
assertTrue(expandedRows[0].hasChildren)
|
||||||
|
assertTrue(expandedRows[0].isExpanded)
|
||||||
|
assertEquals(1, expandedRows[0].completedSubtasksCount)
|
||||||
|
assertEquals(2, expandedRows[0].totalSubtasksCount)
|
||||||
|
assertEquals("1.1", expandedRows[1].todo.id)
|
||||||
|
assertEquals(1, expandedRows[1].depth)
|
||||||
|
assertEquals("1.2", expandedRows[2].todo.id)
|
||||||
|
assertEquals(1, expandedRows[2].depth)
|
||||||
|
assertEquals("2", expandedRows[3].todo.id)
|
||||||
|
assertEquals(0, expandedRows[3].depth)
|
||||||
|
|
||||||
|
// Collapsed state
|
||||||
|
val collapsedRows = flattenTodoTree(tree, collapsedParentIds = setOf("1"))
|
||||||
|
assertEquals(2, collapsedRows.size)
|
||||||
|
assertEquals("1", collapsedRows[0].todo.id)
|
||||||
|
assertTrue(collapsedRows[0].hasChildren)
|
||||||
|
assertFalse(collapsedRows[0].isExpanded)
|
||||||
|
assertEquals("2", collapsedRows[1].todo.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -31,14 +31,20 @@ class RefreshOnChangeTest {
|
|||||||
|
|
||||||
private class TestViewModel : ViewModel() {
|
private class TestViewModel : ViewModel() {
|
||||||
val state = MutableStateFlow("initial")
|
val state = MutableStateFlow("initial")
|
||||||
|
var job: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
job =
|
||||||
refreshOnChange(
|
refreshOnChange(
|
||||||
eventType = ChangeEvents.CRON,
|
eventType = ChangeEvents.CRON,
|
||||||
apiCall = { NetworkResult.Success("refreshed") },
|
apiCall = { NetworkResult.Success("refreshed") },
|
||||||
onSuccess = { state.value = it },
|
onSuccess = { state.value = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cleanup() {
|
||||||
|
job?.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
@@ -55,6 +61,7 @@ class RefreshOnChangeTest {
|
|||||||
fun testRefreshOnChange_updatesOnMatchingEvent() =
|
fun testRefreshOnChange_updatesOnMatchingEvent() =
|
||||||
runTest(testDispatcher) {
|
runTest(testDispatcher) {
|
||||||
val viewModel = TestViewModel()
|
val viewModel = TestViewModel()
|
||||||
|
try {
|
||||||
// Let the init coroutine run so the collector actually subscribes
|
// Let the init coroutine run so the collector actually subscribes
|
||||||
// before the event fires (replay=0 — events before subscription
|
// before the event fires (replay=0 — events before subscription
|
||||||
// are dropped, matching production where VMs subscribe at
|
// are dropped, matching production where VMs subscribe at
|
||||||
@@ -63,15 +70,22 @@ class RefreshOnChangeTest {
|
|||||||
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON))
|
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON))
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals("refreshed", viewModel.state.value)
|
assertEquals("refreshed", viewModel.state.value)
|
||||||
|
} finally {
|
||||||
|
viewModel.cleanup()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testRefreshOnChange_ignoresOtherEventTypes() =
|
fun testRefreshOnChange_ignoresOtherEventTypes() =
|
||||||
runTest(testDispatcher) {
|
runTest(testDispatcher) {
|
||||||
val viewModel = TestViewModel()
|
val viewModel = TestViewModel()
|
||||||
|
try {
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS))
|
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS))
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals("initial", viewModel.state.value)
|
assertEquals("initial", viewModel.state.value)
|
||||||
|
} finally {
|
||||||
|
viewModel.cleanup()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package com.m57.hermescontrol.ui.mcp
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||||
|
import com.m57.hermescontrol.data.model.McpServerToolInfo
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class McpJsonParserTest {
|
||||||
|
@Test
|
||||||
|
fun testParseClaudeDesktopMcpServersWrapper() {
|
||||||
|
val json =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"sqlite": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcp-server-sqlite", "--db-path", "/path/to/db.sqlite"],
|
||||||
|
"env": {
|
||||||
|
"DEBUG": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"url": "https://api.github.com/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer gh_secret_123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parsed = McpJsonParser.parse(json)
|
||||||
|
assertEquals(2, parsed.size)
|
||||||
|
|
||||||
|
val sqlite = parsed.find { it.name == "sqlite" }
|
||||||
|
assertNotNull(sqlite)
|
||||||
|
assertEquals("uvx", sqlite?.command)
|
||||||
|
assertEquals(listOf("mcp-server-sqlite", "--db-path", "/path/to/db.sqlite"), sqlite?.args)
|
||||||
|
assertEquals(mapOf("DEBUG" to "1"), sqlite?.env)
|
||||||
|
assertEquals("none", sqlite?.auth)
|
||||||
|
|
||||||
|
val github = parsed.find { it.name == "github" }
|
||||||
|
assertNotNull(github)
|
||||||
|
assertEquals("https://api.github.com/mcp", github?.url)
|
||||||
|
assertEquals("header", github?.auth)
|
||||||
|
assertEquals("gh_secret_123", github?.bearerToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseSnakeCaseMcpServersWrapper() {
|
||||||
|
val json =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"mcp_servers": {
|
||||||
|
"filesystem": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parsed = McpJsonParser.parse(json)
|
||||||
|
assertEquals(1, parsed.size)
|
||||||
|
assertEquals("filesystem", parsed[0].name)
|
||||||
|
assertEquals("npx", parsed[0].command)
|
||||||
|
assertEquals(listOf("-y", "@modelcontextprotocol/server-filesystem", "/tmp"), parsed[0].args)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseDirectServerMap() {
|
||||||
|
val json =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"postgres": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/db"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parsed = McpJsonParser.parse(json)
|
||||||
|
assertEquals(1, parsed.size)
|
||||||
|
assertEquals("postgres", parsed[0].name)
|
||||||
|
assertEquals("npx", parsed[0].command)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseSingleServerObject() {
|
||||||
|
val json =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"name": "custom-tools",
|
||||||
|
"command": "python3",
|
||||||
|
"args": ["-m", "mcp_server"],
|
||||||
|
"auth": "oauth"
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parsed = McpJsonParser.parse(json)
|
||||||
|
assertEquals(1, parsed.size)
|
||||||
|
assertEquals("custom-tools", parsed[0].name)
|
||||||
|
assertEquals("python3", parsed[0].command)
|
||||||
|
assertEquals("oauth", parsed[0].auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseInvalidJsonReturnsEmpty() {
|
||||||
|
val parsed = McpJsonParser.parse("not json at all")
|
||||||
|
assertTrue(parsed.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseMalformedArgsAndEnvDoesNotCrash() {
|
||||||
|
val json =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"faulty": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["valid", {"nested": "bad"}, ["array"], 123],
|
||||||
|
"env": {
|
||||||
|
"VALID_KEY": "valid_val",
|
||||||
|
"BAD_KEY": {"nested": "bad"},
|
||||||
|
"BAD_LIST": [1, 2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parsed = McpJsonParser.parse(json)
|
||||||
|
assertEquals(1, parsed.size)
|
||||||
|
val server = parsed[0]
|
||||||
|
assertEquals("faulty", server.name)
|
||||||
|
assertEquals(listOf("valid", "123"), server.args)
|
||||||
|
assertEquals(mapOf("VALID_KEY" to "valid_val"), server.env)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTokenOverheadEstimator() {
|
||||||
|
val tools =
|
||||||
|
listOf(
|
||||||
|
McpServerToolInfo(name = "read_file", schemaChars = 400),
|
||||||
|
McpServerToolInfo(name = "write_file", schemaChars = 401),
|
||||||
|
McpServerToolInfo(name = "no_chars", schemaChars = null),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ceil(400/4) = 100, ceil(401/4) = 101 -> sum = 201
|
||||||
|
val est = McpTokenEstimator.estimateTokens(tools)
|
||||||
|
assertEquals(201, est)
|
||||||
|
|
||||||
|
val formatted = McpTokenEstimator.formatTokenOverhead(tools.size, est)
|
||||||
|
assertEquals("3 tools • ~201 tokens", formatted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTokenOverheadLargeFormatting() {
|
||||||
|
val tools =
|
||||||
|
listOf(
|
||||||
|
McpServerToolInfo(name = "tool_huge", schemaChars = 12800),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ceil(12800/4) = 3200 tokens -> ~3.2k tokens
|
||||||
|
val est = McpTokenEstimator.estimateTokens(tools)
|
||||||
|
assertEquals(3200, est)
|
||||||
|
val formatted = McpTokenEstimator.formatTokenOverhead(49, est)
|
||||||
|
assertEquals("49 tools • ~3.2k tokens", formatted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTokenOverheadNoSchemaChars() {
|
||||||
|
val tools =
|
||||||
|
listOf(
|
||||||
|
McpServerToolInfo(name = "tool1", schemaChars = null),
|
||||||
|
McpServerToolInfo(name = "tool2", schemaChars = 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val est = McpTokenEstimator.estimateTokens(tools)
|
||||||
|
assertNull(est)
|
||||||
|
assertEquals("2 tools", McpTokenEstimator.formatTokenOverhead(2, est))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHealthStatusResolution() {
|
||||||
|
// In flight
|
||||||
|
assertEquals(
|
||||||
|
McpHealthStatus.TESTING,
|
||||||
|
McpHealthStatus.resolve(
|
||||||
|
isTesting = true,
|
||||||
|
testResult = null,
|
||||||
|
serverStatus = "ok",
|
||||||
|
serverError = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Healthy result
|
||||||
|
assertEquals(
|
||||||
|
McpHealthStatus.HEALTHY,
|
||||||
|
McpHealthStatus.resolve(
|
||||||
|
isTesting = false,
|
||||||
|
testResult = McpServerTestResponse(ok = true),
|
||||||
|
serverStatus = null,
|
||||||
|
serverError = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auth needed
|
||||||
|
assertEquals(
|
||||||
|
McpHealthStatus.AUTH_REQUIRED,
|
||||||
|
McpHealthStatus.resolve(
|
||||||
|
isTesting = false,
|
||||||
|
testResult = McpServerTestResponse(ok = false, error = "OAuth authentication required"),
|
||||||
|
serverStatus = null,
|
||||||
|
serverError = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error
|
||||||
|
assertEquals(
|
||||||
|
McpHealthStatus.ERROR,
|
||||||
|
McpHealthStatus.resolve(
|
||||||
|
isTesting = false,
|
||||||
|
testResult = McpServerTestResponse(ok = false, error = "Process exited with 1"),
|
||||||
|
serverStatus = null,
|
||||||
|
serverError = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fallback from status
|
||||||
|
assertEquals(
|
||||||
|
McpHealthStatus.HEALTHY,
|
||||||
|
McpHealthStatus.resolve(
|
||||||
|
isTesting = false,
|
||||||
|
testResult = null,
|
||||||
|
serverStatus = "running",
|
||||||
|
serverError = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.m57.hermescontrol.ui.memory
|
package com.m57.hermescontrol.ui.memory
|
||||||
|
|
||||||
import com.m57.hermescontrol.data.model.MemoryProviderStatusRow
|
import com.m57.hermescontrol.data.model.MemoryProviderStatusRow
|
||||||
|
import com.m57.hermescontrol.data.model.MemoryResetRequest
|
||||||
|
import com.m57.hermescontrol.data.model.MemoryResetResponse
|
||||||
import com.m57.hermescontrol.data.model.MemoryResponse
|
import com.m57.hermescontrol.data.model.MemoryResponse
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||||
|
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -12,6 +15,7 @@ import io.mockk.mockkObject
|
|||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
@@ -34,6 +38,8 @@ import retrofit2.Response
|
|||||||
class MemoryViewModelTest {
|
class MemoryViewModelTest {
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private lateinit var mockApi: HermesApiService
|
private lateinit var mockApi: HermesApiService
|
||||||
|
private val mockSwitchFlow = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
|
private val mockConnSwitchFlow = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
|
|
||||||
private val memoryResponse =
|
private val memoryResponse =
|
||||||
MemoryResponse(
|
MemoryResponse(
|
||||||
@@ -54,6 +60,9 @@ class MemoryViewModelTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
mockkObject(ApiClient)
|
mockkObject(ApiClient)
|
||||||
|
mockkObject(ProfileSwitchCoordinator)
|
||||||
|
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
|
||||||
|
every { ProfileSwitchCoordinator.connectionSwitched } returns mockConnSwitchFlow
|
||||||
mockApi = mockk(relaxed = true)
|
mockApi = mockk(relaxed = true)
|
||||||
every { ApiClient.hermesApi } returns mockApi
|
every { ApiClient.hermesApi } returns mockApi
|
||||||
coEvery { mockApi.getMemory() } returns Response.success(memoryResponse)
|
coEvery { mockApi.getMemory() } returns Response.success(memoryResponse)
|
||||||
@@ -77,9 +86,35 @@ class MemoryViewModelTest {
|
|||||||
assertEquals(1024L, state.memory?.builtin_files?.memory)
|
assertEquals(1024L, state.memory?.builtin_files?.memory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `profile switch triggers silent reload`() {
|
||||||
|
val vm = MemoryViewModel()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
coVerify(exactly = 0) { mockApi.getMemory() }
|
||||||
|
|
||||||
|
mockSwitchFlow.tryEmit("yasmin")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { mockApi.getMemory() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `connection profile switch triggers silent reload`() {
|
||||||
|
val vm = MemoryViewModel()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
coVerify(exactly = 0) { mockApi.getMemory() }
|
||||||
|
|
||||||
|
mockConnSwitchFlow.tryEmit("server-remote")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { mockApi.getMemory() }
|
||||||
|
}
|
||||||
|
|
||||||
@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()
|
||||||
@@ -98,20 +133,24 @@ 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
|
||||||
fun `resetMemory posts target and reloads`() {
|
fun `resetMemory posts target and reloads`() {
|
||||||
coEvery { mockApi.resetMemory(mapOf("target" to "all")) } returns
|
coEvery { mockApi.resetMemory(MemoryResetRequest(target = "all")) } returns
|
||||||
Response.success(emptyMap<String, String>())
|
Response.success(MemoryResetResponse(ok = true))
|
||||||
val vm = MemoryViewModel()
|
val vm = MemoryViewModel()
|
||||||
vm.load()
|
vm.load()
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
vm.resetMemory("all")
|
vm.resetMemory("all")
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
coVerify { mockApi.resetMemory(mapOf("target" to "all")) }
|
coVerify { mockApi.resetMemory(MemoryResetRequest(target = "all")) }
|
||||||
assertEquals("Memory (all) reset successfully", vm.uiState.value.toastMessage)
|
assertEquals("Memory (all) reset successfully", vm.uiState.value.toastMessage)
|
||||||
// One load + one reload after reset.
|
// One load + one reload after reset.
|
||||||
coVerify(exactly = 2) { mockApi.getMemory() }
|
coVerify(exactly = 2) { mockApi.getMemory() }
|
||||||
@@ -119,13 +158,17 @@ class MemoryViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `resetMemory failure shows error toast`() {
|
fun `resetMemory failure shows error toast`() {
|
||||||
coEvery { mockApi.resetMemory(mapOf("target" to "memory")) } returns
|
coEvery { mockApi.resetMemory(MemoryResetRequest(target = "memory")) } returns
|
||||||
Response.error(500, "boom".toResponseBody())
|
Response.error(500, "boom".toResponseBody())
|
||||||
val vm = MemoryViewModel()
|
val vm = MemoryViewModel()
|
||||||
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
|
||||||
|
|||||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user