* perf: optimize synchronous I/O in AuthLoginViewModel
Replace blocking OkHttp .execute() with a suspending .await() extension
(CallExt.kt) backed by suspendCancellableCoroutine, freeing Dispatchers.IO
threads during network waits. Probe/connect paths made suspend.
- Call.await() guards both onResponse/onFailure with continuation.isActive
to avoid AlreadyResume on cancellation.
- Removes non-asserting AuthLoginViewModelBenchmark timing dump.
* perf: optimize synchronous I/O in AuthLoginViewModel
Replaced the blocking `.execute()` OkHttp calls with a suspending `.await()` extension function inside `AuthLoginViewModel`. This frees up the `Dispatchers.IO` threads by deferring the blocking operations to OkHttp's internal task runner, providing a safer async approach and preventing Thread pool starvation on slow network responses.
Modified `probeDashboardInternal` and `connectBasicAuth` to be proper `suspend` functions and resolved corresponding unit tests.
* fix(test): add missing import io.mockk.any in AuthLoginViewModelTest
* fix(test): fix mockk imports and Log matchers in AuthLoginViewModelTest
* fix(test): use persistent mockProbeClient in AuthLoginViewModelTest
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
In gated mode (basic-auth cookie flow) the dashboard 401s any request that
carries an Authorization: Bearer header alongside the valid session cookie
(verified live 2026-07-23). ApiClient.authInterceptor stamped the WS ticket
as a Bearer header on every REST call, so every non-chat tab (skills, cron,
config, tools, mcp, ...) failed with "token expired" while the WS chat
(?ticket=) kept working.
- Add AuthManager.isGatedMode() (wsAuthParam == "ticket").
- authInterceptor skips the Bearer header in gated mode; loopback/token
behavior unchanged.
- TokenRefreshAuthenticator bails in gated mode instead of attempting the
loopback-only SPA token refresh.
- ApiClientTest: add gated/loopback regression tests. 7/7 pass, ktlint clean.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* perf: Hoist regexes in MarkdownText.kt to avoid recompilation
Resolves a performance issue where `bulletRe`, `taskRe`, and `orderedRe`
were instantiated and compiled inside the `parseBlocks` function on every
call, causing unnecessary overhead during repeated markdown parsing (e.g.,
when chat messages stream in).
These regex objects have been hoisted to file-level `private val`
constants (`BULLET_RE`, `TASK_RE`, `ORDERED_RE`), ensuring they are compiled
only once when the class is loaded.
A benchmark `MarkdownTextBenchmark.kt` was added to verify the
performance change, showing an approximate 50ms improvement over 10k
iterations.
* perf: Hoist regexes in MarkdownText.kt to avoid recompilation
Resolves a performance issue where `bulletRe`, `taskRe`, and `orderedRe`
were instantiated and compiled inside the `parseBlocks` function on every
call, causing unnecessary overhead during repeated markdown parsing (e.g.,
when chat messages stream in).
These regex objects have been hoisted to file-level `private val`
constants (`BULLET_RE`, `TASK_RE`, `ORDERED_RE`), ensuring they are compiled
only once when the class is loaded.
A benchmark `MarkdownTextBenchmark.kt` was added to verify the
performance change, showing an approximate 50ms improvement over 10k
iterations.
* perf: Hoist regexes in MarkdownText.kt to avoid recompilation
Resolves a performance issue where `bulletRe`, `taskRe`, and `orderedRe`
were instantiated and compiled inside the `parseBlocks` function on every
call, causing unnecessary overhead during repeated markdown parsing (e.g.,
when chat messages stream in).
These regex objects have been hoisted to file-level `private val`
constants (`BULLET_RE`, `TASK_RE`, `ORDERED_RE`), ensuring they are compiled
only once when the class is loaded.
A benchmark `MarkdownTextBenchmark.kt` was added to verify the
performance change, showing an approximate 50ms improvement over 10k
iterations.
* Test ServerCollectionOps data manipulation utilities
Added comprehensive unit tests for `ServerCollectionOps.kt` to cover all scenarios for `addOrUpdate`, `selfHealed`, and resolution properties (`resolvedBaseUrl`, `resolvedHost`, `resolvedPort`). Also updated the function name `addOrReplaceServer` to `addOrUpdate` to match the intended signature in the issue description, and updated affected call sites.
* Fix ktlint formatting issues and ensure build succeeds
Fixed ktlint errors in `ServerCollectionOpsTest.kt` by correctly formatting arguments and expressions to comply with project style guidelines. Ran `assembleDebug` and tests locally to verify build health.
* fix(#688): route expired auth sessions to sign-in
Adopt global 401 latch, WS disconnect, and Back/profile guards.
* chore: clean up formatting noise
* fix(ktlint): reorder imports to place java and kotlinx at end
* fix(ktlint): place java.io.IOException at end of imports
* test: fix NavBackStack type parameter in AuthenticationRoutingTest
* test: add AuthSessionState tests to AuthLoginViewModelTest
* fix(ktlint): fix import order in AuthLoginViewModelTest
* fix(#686): implement robust history pagination
Honor server-returned offset, degrade gracefully on older servers, and parse JSON tool results.
* fix(ci): repair unit-test compilation and pagination test mocks
- Remove trailing blank line in SessionMessagesResponse.kt (ktlint)
- Replace unresolved io.mockk.eq/coAnswers usage in ChatViewModelTest with
an ordered-call counter inside coAnswers, which compiles and runs on
MockK 1.14.11 (eq is a matcher-scope member, not a top-level import)
- Align HermesApiServiceMockWebServerTest with the PR's JsonElement content
model (contentText) and the new include_compacted=true default query param
All unit tests pass locally with the ByteBuddy agent preload.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Introduce ChatScrollController owning all chat scroll behavior:
- continuous bottom-follow intent tracked from LazyListState (not only on data change)
- pixel-precise bottom detection (last item edge vs viewport, small tolerance)
- streaming follow that pauses on upward scroll and never forces the reader down
- tail updates keyed on messages/streaming/thinking/subagent/clarify
- unread (new-message) count shown on the scroll-to-bottom FAB, cleared on tap
- paging anchor preserved by message id across older-history prepend
- all scroll commands (send/FAB/session switch/search/paging) serialized via one scope
Closes#682
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(navigation): regroup Kanban to AUTOMATE and Gateway to INSPECT in navigation drawer
* feat(navigation): change Achievements screen icon to EmojiEvents (trophy)
* perf(model): optimize model tab load speed with two-phase fetch and loadQuick
* perf: optimize data loading across Config, System, Profiles, and Billing tabs
* chore: remove leftover bottom nav references and unused updatePrimaryScreens
* style: fix ktlint violations in Config/Profiles/Billing ViewModels
- property/argument-list wrapping for async(...) calls
- multiline-expression wrapping for async blocks
- remove unused import in ConfigViewModel
- break long error-message string literals to stay under 120 cols
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
* ui: input bar reasoning chip tweaks
- Remove emoji from reasoning level labels (dropdown + chip text)
- Reduce reasoning chip horizontal padding (8dp -> 4dp)
- Move reasoning chip to right side (after spacer, next to mic)
- Increase model chip minimum width (120dp -> 140dp)
- Remove unused width import
* fix(ui): add missing widthIn import in ComposerToolbar
Resolves 'Unresolved reference widthIn' compile error at
ComposerToolbar.kt:95 that broke CI (unit-tests, android-lint,
instrumented-tests all fail on compileDebugKotlin).
* ui(chat): enhance input bar layout, reasoning menu header, and card shadow
Give ChatInputBar a floating rounded container with shadow contrast. Make model chip take available space and add header to reasoning menu.
* fix(ui): remove trailing blank line in ComposerToolbar for ktlint
Fix standard:no-consecutive-blank-lines ktlint violation.
* ui(model): enhance ModelPickerDialog with rounded cards and provider badges
Apply card styling, rounded corners (24dp dialog, 12dp cards), provider badges, and shadow contrast to ModelPickerDialog.
* feat(model): add in-dialog model pinning to ModelPickerDialog
Add pin/unpin icon buttons to model cards in ModelPickerDialog across both ModelScreen and ChatScreen.
* ui(model): remove provider badge chip and eliminate bottom whitespace in ModelPickerDialog
Replace bottom Cancel button with top-right Close icon to eliminate bottom whitespace. Remove inline provider badge chip from model cards.
* perf(model): optimize ModelPickerDialog load time with lazy items and O(1) set lookup
Lazy-register individual model items in LazyColumn and memoize pinnedSet to prevent synchronous rendering lag when opening dialog.
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
When the initial REST page returns empty messages, hasOlderMessages
was still set to true (offset > 0), causing an infinite loop of empty
load-more requests. Guard with && chatMessages.isNotEmpty().
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
* feat(chat): rich message bubbles with reasoning, code, clarify, subagent cards (#659#660#661#662#663#664)
* fix(chat): render ClarifyBubble at bottom of message list, not as inline SYSTEM message
Fixes two on-device bugs:
1. ClarifyBubble now renders at the very bottom of the LazyColumn
(after all messages, streaming message, typing indicator, and subagent
indicators) instead of inline among regular messages.
2. Bubble disappears when an option is selected or dismissed — the
ClarifyBubble is driven by state.clarifyRequest instead of a
persisted ChatMessage.clarifyInfo, so clearing the request state
removes it from the list entirely.
* fix(chat): add text input field to ClarifyBubble for custom typed answers
* fix(chat): render ReasoningCard at list level with stable key, not inside AssistantBubble
* fix(chat): address agy review — hoist regex, fix a11y, cache anim specs, disable animateContentSize while streaming
* fix(chat): replace hardcoded Color() with theme constants to pass CI checkColorLiterals
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
* feat(chat): add reasoningLevel to ChatUiState + ViewModel methods
* feat(chat): add ComposerToolbar with model + reasoning chips
* refactor(chat): rewrite ChatInputBar to two-row layout
* feat(chat): wire ComposerToolbar into ChatScreen
* fix(chat): make model chip fixed 120dp with horizontal scroll for long names
* feat(chat): reasoning chip opens dropdown menu with all 9 levels instead of cycling
* fix(chat): remove Auto level from reasoning menu — backend only supports none..ultra, default is medium
* feat(chat): sync model + reasoning level from backend session.resume response
* fix(chat): handle session.info events for model/reasoning sync + fix null value 4002
* fix(chat): safety timeout for createNewSession + try-catch event loop
* fix(chat): address agy review findings
- Restore InsertDriveFile icon for non-image attachments
- Add widthIn(max=120.dp) constraint on attachment filename
- Restore stringResource reference for remove-attachment description
- Remove dead cycleReasoningLevel() method (dropdown supersedes)
- Fix createNewSession timeout race with generation counter
* fix(chat): update instrumented test for two-row layout
chatScreen_renders_and_acceptsInput had an assertion that the mic
button disappears when text is entered — this was the OLD single-row
layout behavior. The new two-row composer always shows the mic button
in the toolbar row, independent of input state.
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
* fix(chat): make chat input composer more compact and flush
* fix(chat): fix chat page compose structure (issue #652)
- Resolve EmptyState rendering alongside empty LazyColumn by wrapping in if/else
- Flatten nested weight(1f) layout by moving search bar to ChatScreen
* style(chat): run ktlint auto-format and fix missing padding import
* style(chat): remove unused imports in ChatComposer and ChatMessageList
* fix: correct bulk session delete endpoint + surface deleted count
- Fix Retrofit @POST path from 'api/sessions/delete' to 'api/sessions/bulk-delete'
- Change response type from Response<Unit> to Response<BulkDeleteResponse>
- Add BulkDeleteResponse model (ok, deleted) matching backend contract
- Surface backend 'deleted' count in toast message instead of using client-side count
Backend contract (web_server.py:10750):
POST /api/sessions/bulk-delete
Body: { "ids": [...], "delete_all": false }
Response: { "ok": true, "deleted": <count> }
Fixes#651
* fix: replace client-side session list filter with full refresh after bulk delete
- Call loadSessions() + loadStats() after successful bulk delete instead of
filtering sessions/searchResults locally via 'ids' — fixes state desync
when backend deletes fewer sessions than requested (partial success) and
is correct for delete_all=true where ids may be empty
- Show 'No sessions were deleted' toast when deletedCount == 0
- Extract toastMsg to separate val to satisfy ktlint lint rules
Addresses review findings: UI state desync, delete_all handling, hardcoded
pluralizing, and zero-deletion UX.
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
* Fix#647: resolve Default profile URL from top-level login URL
ConnectionProfile.resolvedBaseUrl (used directly by ConnectionSection for
the profile list display) silently fell back to the hardcoded loopback
127.0.0.1:9119 whenever a profile's own baseUrl was null, instead of using
the URL the user actually authenticated against.
- Add ConnectionProfile.resolveBaseUrl(topLevelBaseUrl) that prefers the
profile's own baseUrl, then the top-level login URL, then legacy loopback.
- Store-level ServerCollectionOps.resolvedBaseUrl now applies the same
fallback so a null-baseUrl profile still shows the real gateway.
- ConnectionSection, SettingsViewModel and ConnectViewModel resolve against
AuthManager.getBaseUrl() so the loopback fallback can never surface.
- Add Issue647ProfileUrlTest covering login-stamp, null-baseUrl fallback,
own-URL precedence, fresh-install loopback, and store-level resolution.
- Update existing AuthManagerTest assertions to the new property form.
* Fix#647 (root cause): cache login URL at top-level baseUrl
The previous change only made profile resolution prefer the top-level
login URL, but setBaseUrl() never actually wrote the top-level baseUrl
field — so it stayed DEFAULT_BASE_URL (https://127.0.0.1:9119/) forever.
A Default profile seeded with the loopback default (or whose own baseUrl
was never stamped) therefore still displayed the loopback URL after
logging in via a custom LAN address.
setBaseUrl() now also stamps the top-level baseUrl, making it the
authoritative "last successful login URL" cache. getBaseUrl() and the
store-level resolvedBaseUrl (profile-list display) now reflect the URL
actually authenticated against.
Add regression tests mirroring the exact AuthLoginViewModel.connect()
sequence (seed with loopback -> setBaseUrl(loginUrl) -> token) asserting
the Default profile, top-level baseUrl and store display all resolve to
the login URL, never loopback.
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
- CookieSerialization: add SESSION_COOKIE_NAMES (__Host-, __Secure-, bare) and
isSessionCookieName(); isSessionCookie now matches any variant.
- PersistentCookieJar.getSessionCookieValue(): iterate SESSION_COOKIE_NAMES,
returning the strictest prefixed match when present.
- HermesWsClient.refreshWsTicketIfNeeded(): drop the bare-name session-cookie
preflight in gated mode — the server may hold a valid refresh cookie under a
prefixed name; the authenticated ticket endpoint is the authority. Ticket URL
already derived from endpoint (issue #634).
- HermesWsClient: stop logging WS close/failure reason strings and exception
messages (class name only) to avoid leaking server context / URLs.
- ProfileScopeInterceptor: match profile-scoped paths via endpoint-relative
request path so reverse-proxy prefixes (e.g. /hermes/api/...) are honored;
wrap endpoint read in runCatching.
Verified: ktlintCheck, testDebugUnitTest, lintDebug all green locally.
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
* fix(ux): keep Settings highlighted in bottom nav on drill-down sub-pages (issue #637)
Map Settings drill-down destinations (SettingsConnection, SettingsAppearance, SettingsChat, SettingsNavBar, SettingsBehavior, SettingsAbout) back to SettingsScreen for bottom-nav selection only - navigation is unaffected.
- Add selectedBottomNavDestination() helper in Navigation.kt
- Extract bar-height logic into Modifier.bottomNavigationHeight() (ICON_AND_TEXT now uses heightIn(min=80.dp) so the standard bar can grow)
- BottomNavigationSelectionTest (JVM): selection mapping logic
- BottomNavigationLayoutTest (androidTest): per-mode bar height assertions
* docs(navigation): warn that new Settings sub-pages must be added to settingsDestinations
Addresses agy review (PR #643): documents the fragility of the hardcoded settingsDestinations set so future Settings drill-down screens don't silently drop the bottom-nav highlight. The sealed-class/marker-interface refactor was declined as scope creep (issue #637 explicitly scopes Settings-only; keys are flat data objects).
* fix: language selector
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
* [feature] issue #634: HTTPS + reverse-proxy server endpoint support
- Add ServerEndpoint model (scheme-aware base URL, resolve/webSocketUrl/relativeRequestPath, cleartext policy)
- AuthManager: baseUrl is canonical; setHost/setPort derive from endpoint; endpointForBuild() for scheme-aware URLs
- ApiClient/CookieManager/HermesWsClient: route through ServerEndpoint (no hardcoded http://)
- Data migration: ServerUrlMigration converts legacy host/port to baseUrl
- UI: Connect/AuthLogin/Settings now use a single Server URL field with transport warning
- Build: ALLOW_CLEARTEXT buildConfigField + manifest placeholder for dev
- Tests: ServerEndpointTest + ServerUrlMigrationTest; adapt existing suite to baseUrl API
Co-Authored-By: M57 <Hy4ri@users.noreply.github.com>
* fix(authlogin): basic-auth derives token automatically, no manual entry
connectBasicAuth() built a fresh OkHttpClient for the password-login call and
a different one for the ws-ticket call, so the Set-Cookie from
/auth/password-login (the session token lives in the cookie, not the JSON body)
never reached /api/auth/ws-ticket. That 401'd the ticket request, returned null,
and the UI fell back to asking the user to paste a token.
Both calls now use the shared OkHttpProvider.probe client (same CookieManager
jar), so the session cookie flows through and the WS ticket mints automatically —
matching the dashboard contract (routes.py: password-login sets Set-Cookie,
ws-ticket requires it).
Add AuthLoginBasicAuthFlowTest proving the cookie propagates on the shared
client (and that ws-ticket 401s without it).
Fixes the basic-auth token prompt reported against issue #634.
* chore: remove TASK-634-TESTS.md (stray task-tracking doc, not part of feature)
* ci: broaden .gitguardian.yml test-file ignores (unit + instrumented + variant dirs)
* feat(authlogin): smart auth-mode detection from /api/status + drop pairing-string login
- Replace 302-sniff probe with authoritative GET /api/status read
(auth_required + auth_providers) to derive DashboardAuthMode.
- Add OAUTH mode (stubbed, 'coming soon' UI, TODO tracks issue #639).
- Render login UI by detected mode: token (loopback), username+password
(basic/gated), or OAuth notice.
- Remove legacy pairing-string box + onPairingString/applyLegacyPairing.
- Extract internal deriveAuthMode() + add 5 unit tests.
Closes nothing; OAuth tracked in #639.
* fix(chat): don't block WS connect on empty token in gated (ticket) mode
connectWebSocket() gated on getToken() != null, correct for loopback
(token) mode but wrong for gated (ticket) mode: the ticket is minted
fresh by HermesWsClient.refreshWsTicketIfNeeded() from the persisted
session cookie, so getToken() is expected empty at connect time. The
early return left the WS unopened -> DISCONNECTED -> chat showed
'reconnect' immediately after basic-auth login.
Only require a non-blank token in non-gated mode.
Fixes#640.
* test: remove onPairingString test cases for deleted pairing-login path
The pairing-string login (onPairingString/applyLegacyPairing) was removed
from ConnectViewModel, so the 5 tests that exercised it (3 in
ConnectViewModelTest, 2 in E2eIntegrationTest) no longer compile. Remove
them. Also drop the now-orphaned connect_pairing_string from values-ko.
Fixes the Unit & Integration Tests CI failure on PR #638.
---------
Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
Verified against backend source at tip 299e409f15 (tui_gateway/server.py).
- SubscriptionCurrent: add cancel_at_period_end / cancellation_effective_at / cancellation_effective_display (emitted by _serialize_subscription_state, were missing on mobile).
- SubscriptionTier: align to wire shape {tier_id, name, tier_order, dollars_per_month_display, monthly_credits, is_current, is_enabled}; old {tier_name, price, currency, interval} never matched the backend and is unused in-app.
- NOTE: usage.bars UsageBar *_display string model is CORRECT — the backend serializes raw *_usd floats to *_display strings via _serialize_usage_bar before sending. Issue #631 misidentified this as a gap; switching to *_usd would break the bars. No change made there.
Co-authored-by: ronia <ronia@m57>
* feat(billing): adopt subscription.* + usage.bars WS methods (issue #628)
Add Billing/Plan screen + WS client surface for the backend release audit
(hermes-agent 0bf44d557..614dc194e). credits.view was removed upstream;
subscription.{state,preview,change,resume,upgrade} + usage.bars replace it.
- WsMethods: add SUBSCRIPTION_* + USAGE_BARS constants
- BillingModels: @Serializable request/response (snake_case, ignoreUnknownKeys)
- BillingRepository: WS dispatch via HermesWsClient.request + typed decode;
HermesRpcException (-32601) surfaces as featureUnavailable, not a crash
- BillingViewModel: plan/usage load + upgrade/change/resume/cancel actions
- BillingScreen: HermesScaffold composable (plan card, usage bars, actions)
- NavigationKeys + ScreenRegistry + strings (en/ko) wire BillingScreen into
the INSPECT drawer section
Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.
* fix(billing): align models + UI to live gateway wire shape (issue #628)
Live-probed the 6 subscription.* / usage.bars RPCs against the running
backend (hermes-agent dashboard :9119, 2026-07-19). Findings:
- subscription.state returns logged_in / current / tiers / usage (nested),
NOT the legacy subscription_type_id / renews_at fields the models assumed.
- Mutating RPCs (preview/change/resume/upgrade) return ok:false *results*
with error/message/payload (e.g. invalid_token when not portal-authed),
not RPC-level errors — so the ViewModel's HermesRpcException guard never
fired and the screen silently half-decoded null fields.
- subscription.upgrade also returns an idempotency_key not in the model.
Changes:
- BillingModels: re-shape SubscriptionStateResponse around current/tiers/
usage; add ok/error/message/payload envelope + idempotency_key to the
action responses; add SubscriptionCurrent / SubscriptionTier / preview
payload types. All nullable + ignoreUnknownKeys (graceful degrade).
- BillingViewModel: detect ok:false results on every call and route to
featureUnavailable / errorMessage instead of swallowing them.
- BillingScreen: read plan from subscription.current; render a clean
'no active plan' / 'log in to Nous Portal' card on the logged-out path
instead of a broken null plan card.
- strings: add billing_no_active_plan / billing_login_required (en + ko).
Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.
* fix(billing): address independent review (agy) — error routing + icon
Triage of the independent AGY review on PR #629:
Fixed (real issues):
- BillingViewModel: usage.bars RPC failure (-32601) no longer marks the
whole feature unavailable (was blocking the plan card despite the
'non-fatal' comment); it is now correctly non-fatal.
- BillingViewModel: a failed primary subscription load now surfaces
errorMessage even when usage bars loaded (was swallowed by
'sub == null && bars == null').
- BillingScreen: render state.errorMessage as an error card inside
BillingContent so failed mutating actions (upgrade/cancel) are visible
instead of silently disappearing.
- BillingScreen: wrap the dead AccountBalanceWallet expression in a real
Icon() composable in FeatureUnavailableState.
Declined (with rationale, recorded for maintainer):
- Tier-select / upgrade UI (review issue #4): explicitly out of scope per
the PR body — 'Plan-selection UI ... is not built ... left for a
follow-up once backend returns the available plans.'
- Rename snake_case Kotlin props to camelCase (review nit #2): incorrect —
OkHttpProvider uses JsonNamingStrategy.SnakeCase, which maps snake_case
wire keys onto snake_case properties; the props are correct. Fixed the
misleading doc comment instead.
* fix(billing): match REAL logged-in wire shapes + kill null-result crash
The live gateway probe (logged-in Nous Portal session, 2026-07-19) showed
the earlier models assumed the WRONG shape:
- subscription.state.current is {tier_id, tier_name, monthly_credits,
credits_remaining, cycle_ends_at, pending_downgrade_*} — NOT the legacy
subscription_type_id/status/renews_at fields. Screen rendered 'Free / no
plan' for a real logged-in user.
- usage.bars is {available, status, plan_name, plan_bar{remaining_display,
total_display, pct_used, fill_fraction}, topup_bar} — NOT a List<UsageBar>.
- Mutating RPCs return error:'insufficient_scope' when the Portal token
lacks billing:manage scope (ok:false result, not an RPC error).
Crash fix (root cause of 'Billing RPC returned no result payload'):
- BillingRepository.decode() now returns null on a missing/null result
instead of requireNotNull() -> IllegalStateException. All repo methods
return nullable; ViewModel handles null as a non-fatal 'No billing data
returned' instead of crashing.
- BillingScreen.PlanCard / UsageBarRow rewritten to the real fields; the
resume/cancel branch now gates on can_change_plan (no status field exists
on current anymore).
- UsageBarsResponse + UsageBar reshaped to plan_bar/topup_bar; added
billing_credits_remaining / billing_plan / billing_topup strings (en+ko).
Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL. Decode sim
against the captured live responses confirms PlanCard/UsageBarRow/error
paths all resolve with no null-crash.
* fix(billing): decode WS result that arrives as toAny() Map, not JsonElement
Root cause of 'No billing data returned' on a logged-in server: the backend
returns a valid subscription.state result, but HermesWsClient.request()
yields result already converted via JsonElement.toAny() (a Map<String,Any?>),
not a raw JsonElement. BillingRepository.decode() only accepted JsonElement,
so was null and the repo returned null -> ViewModel showed
the generic 'No billing data returned' message. The server DOES have billing
(logged_in:true, tier_name:Free, /usr/bin/bash.10 credits) — the app just couldn't parse it.
Fix: decode() now normalizes whatever arrives — a kotlinx JsonElement OR the
toAny() Map/List — back into a JsonElement via a recursive Any->JsonElement
walk, then deserializes. Both shapes decode correctly now.
This also means the previous 'Billing RPC returned no result payload' crash
and the generic 'no data' message were both symptoms of this same parse gap,
not a missing backend feature.
Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL.
* perf(analytics): preload on launch + split-fetch + disk cache (issue #537)
Cut perceived analytics-tab latency for every user regardless of backend
DB size:
A. Preload analytics (7/30/90) in the background on app launch
(AnalyticsPreloader) so the tab renders instantly when opened.
B. Split the two endpoint fetches: /models (~0.1s, carries the
totals card) now renders immediately while the slow /usage (insights
engine over full message history) streams in behind a placeholder.
C. Persist last successful response to DataStore so a cold app launch
serves last session's data instantly, then refreshes (AnalyticsCacheStore).
Root cause of the slowness is server-side (/api/analytics/usage cold
read of a multi-GB state.db); these client changes make the common
case instant and never block the UI on the slow call.
* fix(analytics): address agy review — null-safety, profile-keyed cache, no main-thread block
Triage of independent agy review (High model) on PR #630:
Fixed (real):
- Blank-on-cold-launch: AnalyticsContent early-returned on null
usage, blanking the tab while the slow /usage call loaded. Now
renders the fast /models half + placeholders; usage-only cards
(totals, daily chart) and Skills/Tools tabs guard on usage != null.
- Profile isolation: cache was keyed by days only -> cross-profile
data leak on switch/cold-start. Now keyed "$days:$profile".
- Main-thread block: ViewModel.init used runBlocking(IO) on the
UI thread at startup. Moved disk-seed into viewModelScope.launch.
- Serialization parity: cache used bare kotlinx Json; switch to
OkHttpProvider.json (ignoreUnknownKeys) so a backend field
addition can't break on-disk decode.
Declined (not introduced by this PR / protected by viewModelScope):
- "safeApiCall swallows CancellationException": pre-existing
NetworkResult.kt behavior; viewModelScope cancellation already
prevents post-clear state updates. Out of scope for this change.
* fix(analytics): crash on tab open — LocalContext is Activity, not Application
AnalyticsScreen cast LocalContext.current as Application, but under
Compose the composition context is the *Activity*, so the cast threw
ClassCastException the moment the tab composed (on tap). Use
.applicationContext, which resolves to the Application instance.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(billing): adopt subscription.* + usage.bars WS methods (issue #628)
Add Billing/Plan screen + WS client surface for the backend release audit
(hermes-agent 0bf44d557..614dc194e). credits.view was removed upstream;
subscription.{state,preview,change,resume,upgrade} + usage.bars replace it.
- WsMethods: add SUBSCRIPTION_* + USAGE_BARS constants
- BillingModels: @Serializable request/response (snake_case, ignoreUnknownKeys)
- BillingRepository: WS dispatch via HermesWsClient.request + typed decode;
HermesRpcException (-32601) surfaces as featureUnavailable, not a crash
- BillingViewModel: plan/usage load + upgrade/change/resume/cancel actions
- BillingScreen: HermesScaffold composable (plan card, usage bars, actions)
- NavigationKeys + ScreenRegistry + strings (en/ko) wire BillingScreen into
the INSPECT drawer section
Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.
* fix(billing): align models + UI to live gateway wire shape (issue #628)
Live-probed the 6 subscription.* / usage.bars RPCs against the running
backend (hermes-agent dashboard :9119, 2026-07-19). Findings:
- subscription.state returns logged_in / current / tiers / usage (nested),
NOT the legacy subscription_type_id / renews_at fields the models assumed.
- Mutating RPCs (preview/change/resume/upgrade) return ok:false *results*
with error/message/payload (e.g. invalid_token when not portal-authed),
not RPC-level errors — so the ViewModel's HermesRpcException guard never
fired and the screen silently half-decoded null fields.
- subscription.upgrade also returns an idempotency_key not in the model.
Changes:
- BillingModels: re-shape SubscriptionStateResponse around current/tiers/
usage; add ok/error/message/payload envelope + idempotency_key to the
action responses; add SubscriptionCurrent / SubscriptionTier / preview
payload types. All nullable + ignoreUnknownKeys (graceful degrade).
- BillingViewModel: detect ok:false results on every call and route to
featureUnavailable / errorMessage instead of swallowing them.
- BillingScreen: read plan from subscription.current; render a clean
'no active plan' / 'log in to Nous Portal' card on the logged-out path
instead of a broken null plan card.
- strings: add billing_no_active_plan / billing_login_required (en + ko).
Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.
* fix(billing): address independent review (agy) — error routing + icon
Triage of the independent AGY review on PR #629:
Fixed (real issues):
- BillingViewModel: usage.bars RPC failure (-32601) no longer marks the
whole feature unavailable (was blocking the plan card despite the
'non-fatal' comment); it is now correctly non-fatal.
- BillingViewModel: a failed primary subscription load now surfaces
errorMessage even when usage bars loaded (was swallowed by
'sub == null && bars == null').
- BillingScreen: render state.errorMessage as an error card inside
BillingContent so failed mutating actions (upgrade/cancel) are visible
instead of silently disappearing.
- BillingScreen: wrap the dead AccountBalanceWallet expression in a real
Icon() composable in FeatureUnavailableState.
Declined (with rationale, recorded for maintainer):
- Tier-select / upgrade UI (review issue #4): explicitly out of scope per
the PR body — 'Plan-selection UI ... is not built ... left for a
follow-up once backend returns the available plans.'
- Rename snake_case Kotlin props to camelCase (review nit #2): incorrect —
OkHttpProvider uses JsonNamingStrategy.SnakeCase, which maps snake_case
wire keys onto snake_case properties; the props are correct. Fixed the
misleading doc comment instead.
* fix(billing): match REAL logged-in wire shapes + kill null-result crash
The live gateway probe (logged-in Nous Portal session, 2026-07-19) showed
the earlier models assumed the WRONG shape:
- subscription.state.current is {tier_id, tier_name, monthly_credits,
credits_remaining, cycle_ends_at, pending_downgrade_*} — NOT the legacy
subscription_type_id/status/renews_at fields. Screen rendered 'Free / no
plan' for a real logged-in user.
- usage.bars is {available, status, plan_name, plan_bar{remaining_display,
total_display, pct_used, fill_fraction}, topup_bar} — NOT a List<UsageBar>.
- Mutating RPCs return error:'insufficient_scope' when the Portal token
lacks billing:manage scope (ok:false result, not an RPC error).
Crash fix (root cause of 'Billing RPC returned no result payload'):
- BillingRepository.decode() now returns null on a missing/null result
instead of requireNotNull() -> IllegalStateException. All repo methods
return nullable; ViewModel handles null as a non-fatal 'No billing data
returned' instead of crashing.
- BillingScreen.PlanCard / UsageBarRow rewritten to the real fields; the
resume/cancel branch now gates on can_change_plan (no status field exists
on current anymore).
- UsageBarsResponse + UsageBar reshaped to plan_bar/topup_bar; added
billing_credits_remaining / billing_plan / billing_topup strings (en+ko).
Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL. Decode sim
against the captured live responses confirms PlanCard/UsageBarRow/error
paths all resolve with no null-crash.
* fix(billing): decode WS result that arrives as toAny() Map, not JsonElement
Root cause of 'No billing data returned' on a logged-in server: the backend
returns a valid subscription.state result, but HermesWsClient.request()
yields result already converted via JsonElement.toAny() (a Map<String,Any?>),
not a raw JsonElement. BillingRepository.decode() only accepted JsonElement,
so was null and the repo returned null -> ViewModel showed
the generic 'No billing data returned' message. The server DOES have billing
(logged_in:true, tier_name:Free, /usr/bin/bash.10 credits) — the app just couldn't parse it.
Fix: decode() now normalizes whatever arrives — a kotlinx JsonElement OR the
toAny() Map/List — back into a JsonElement via a recursive Any->JsonElement
walk, then deserializes. Both shapes decode correctly now.
This also means the previous 'Billing RPC returned no result payload' crash
and the generic 'no data' message were both symptoms of this same parse gap,
not a missing backend feature.
Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Turn dead-end empty states into on-ramps by wiring the existing
EmptyState actionLabel/onAction params to real next actions.
Screens wired (real, existing actions):
- Skills (installed): Browse skill hub -> setViewMode(HUB)
- Skills (hub): Retry search -> searchHub(hubQuery)
- Cron Jobs: Create your first job -> openNewJobDialog()
- MCP servers: Add a server -> toggleAddForm() (literal moved to strings.xml)
- Webhooks: Create subscription -> showCreateDialog() (literal moved to strings.xml)
- Channels: Connect a Telegram channel -> startTelegramOnboarding()
- Model: Configure model -> navigateTo(SettingsConnection)
- Sessions (history): Start a new chat -> navigateTo(ChatScreen)
- Analytics: Explore Skills or Cron jobs -> navigateTo(SkillsScreen)
- Keys (no match): Clear search -> query = ""
All new labels use strings.xml (empty_action_* / screenname_empty_*).
Deviations from issue (documented honestly):
- Toolsets, Profiles, Plugins keep Refresh. No in-app create path exists
(toolsets are server-defined toggles; profiles/plugins have no create
dialog wired in UI). Refresh is the correct recovery there, not a fake
'create' CTA. Flagged for follow-up if create flows get built.
- Skills: edited the real private InstalledSkillsView in SkillsScreen.kt;
the duplicate internal one in InstalledSkillsTab.kt is dead code.
Verified: ktlintCheck clean, assembleDebug green.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Co-authored-by: Ronia <ronia@hermes>
Removed docs/color-hardcoded-audit.md per request. Replaced 2 hardcoded
Color literals with theme tokens; added CI guard blocking new literals.
Co-authored-by: Hy4ri <hy4ri@users.noreply.github.com>
Replaces the fragile DRAWER_GESTURE_SCREENS global set + defensive
LaunchedEffect(snapTo(Closed)) + closeDrawer callback workaround with a
single source of truth: each screen declares its drawer-gesture preference
via HermesScaffold's new drawerGesturesEnabled parameter (default true).
Architecture:
- DrawerGestureController holds the active gesture state. HermesScaffold
reconciles each screen's preference into it via SideEffect; when a screen
opts out while the drawer is open, the controller closes the drawer itself.
- LocalDrawerGestureController CompositionLocal flows the controller from
MainNavigation down to ModalNavigationDrawer, which reads .enabled and
passes it to Material's gesturesEnabled parameter.
- DisableDrawerGestures() helper for entry screens that don't use
HermesScaffold (Landing, AuthLogin, PairingCodeEntry).
- All 6 Settings drill-down sub-pages pass drawerGesturesEnabled = false.
- NavigationController.closeDrawer removed; navigateTo no longer needs a
synchronous drawer-dismiss hook.
Removes: DRAWER_GESTURE_SCREENS set, both LaunchedEffect(gesturesEnabled)
and LaunchedEffect(drawerGesturesEnabled) blocks, drawerGesturesEnabled
local state, closeDrawer callback on NavigationController.
Fixes the recurring drawer-scrim stuck-open bug class (PRs #445, #454, #455).
Verified: ktlintCheck, assembleDebug, testDebugUnitTest all green locally.
Co-authored-by: Hy4ri <hy4ri@users.noreply.github.com>
Split the 2,267-line ChatScreen.kt into 11 single-purpose files under
ui/chat/components/, keeping ChatScreen.kt as a thin (~530 line)
entry compositor that delegates rendering to the new components.
Files extracted:
- ChatConnectionBanner.kt — top banner (reconnecting/disconnected/auth-expired)
- ChatMessageList.kt — message list + search bar overlay + empty state
- ChatComposer.kt — input bar + attachment chips + slash dropdown
- ChatSearchBar.kt — CompactSearchInput + SearchBarRow
- ChatDialogs.kt — ClarifyDialog, SudoPromptDialog, SecretPromptDialog, ReloginDialog
- ChatIndicators.kt — ThinkingIndicator, ReasoningIndicator, SubagentIndicatorRow
- ChatScrollFab.kt — scrollToBottom utils + FAB
- ChatLifecycleEffects.kt — session switch, lifecycle observer, auto-scroll, error snackbars
- ChatReactions.kt — ReactionHeartsOverlay + FloatingHeart animation
- ChatStreaming.kt — StreamingBubbleWithTypingEffect
- ChatLoadingOverlay.kt — full-screen semi-transparent loading overlay
No behavioral change. All composables are public where they need to be
called from ChatScreen, otherwise keep their original visibility.
Unblocks #583 and #584 which were previously too risky to attempt in
the 2.2k-line file.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Add ToastHost interface + override keyword to the 3 ViewModels that
declared clearToast() without implementing the interface. This aligns
them with the other 17 ViewModels and enables polymorphic substitution
where ToastHost is expected.
Fixes#614
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat: auto-scroll long chat title once on load + on tap
* fix: resolve state delegate + suspension compile errors in AutoScrollingTitleText
* fix: use Int scroll offset for animateScrollTo
* fix: measure real container width for title marquee (was always Infinity)
* fix: ease the title marquee scroll (FastOutSlowInEasing + glided return)
* fix: address agy review — cancel overlapping scrolls + one-shot on-load guard + clickable on box
* fix(ui): smooth out top app bar collapse transition
Use dynamic layout padding based on scroll offset to prevent blank gap at the top of content when app bar collapses.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#606): support auth mode + bearer token when adding HTTP MCP servers
- Extend AddMcpServerRequest with auth (none|header|oauth) and bearerToken
- Add auth mode FilterChip selector in HTTP add-server dialog
- Conditionally show masked bearer token field when auth == header
- Wire new fields through McpServersViewModel request builder
Backend contract: exactly one of url/command required; bearer_token
requires auth == "header"; args/env only for stdio. SnakeCase naming
strategy auto-serializes bearerToken -> bearer_token.
Fixes#606
* fix(#606): address agy review — remember PasswordVisualTransformation + FlowRow for auth chips
- Wrap PasswordVisualTransformation in remember {} to avoid per-recomposition allocation
- Use FlowRow for the 3 auth-mode FilterChips so they wrap on narrow screens instead of overflowing
* refactor(settings): drill-down navigation replacing tabbed layout
Replaces the 4-tab settings layout with a native Android-style drill-down:
a single category list (SettingsScreen) where each row pushes a dedicated
sub-page onto the Navigation3 back stack (SettingsConnection / Appearance /
Chat / NavBar / Behavior / About). Each sub-page is its own NavKey destination
registered in Navigation.kt and reuses the existing section composables.
- SettingsScreen becomes the category list with icon + label + summary
(Appearance row shows live theme + language, e.g. "Dark · 한국어").
- New SettingsSubPages.kt hosts the 6 full-screen sub-pages.
- AboutSection promoted out of SettingsScreen into its own sub-page.
- SettingsViewModel remains the single source of truth; no persistence changes.
- Adds settings_summary_* strings for list subtitles.
Consumes the in-app language switcher from the prior commit (AppearancePage
passes appLanguage through to AppearanceSection).
* [bug] fix settings sub-page back button opening drawer on first tap
The ModalNavigationDrawer wraps the whole app and stays gesture-enabled on
the Settings root (a DRAWER_GESTURE_SCREEN). Drilling into a sub-page
(SettingsConnection/Appearance/Chat/NavBar/Behavior/About) disables gestures,
but the existing LaunchedEffect only snapped the drawer closed when it was
already isOpen. The drawer's scrim surface lingered during the transition and
intercepted the first back-button tap, sliding the sidebar open instead of
navigating back.
Now snapTo(Closed) unconditionally whenever gestures are disabled — a safe
no-op when already closed — so the sub-page back button works on the first tap.
* [bug] fix settings sub-page back button opening drawer (gesture-surface race)
The first "back" tap on a settings sub-page opened the sidebar instead of
navigating back. Root cause: Settings root is a gesture-enabled drawer screen,
and the sub-pages are the first destinations ever drilled down from it. When
navigating in, ModalNavigationDrawer's gesture interceptor surface lingered
for a frame (gesture -> non-gesture transition), so the top-left tap was
consumed by the drawer instead of the Back IconButton. The previous snapTo
fix only reset drawer *state*, not the gesture layer, so it didn't help.
Fix: NavigationController now holds a closeDrawer hook (set by MainNavigation)
and invokes it synchronously inside navigateTo() when pushing a non-primary
sub-page. The drawer is dismissed in the same tap handler that triggers the
navigation, before any recomposition, so the sub-page is never tappable while
the gesture surface is still mounted. The LaunchedEffect(gesturesEnabled)
snapTo remains as a backstop for non-navigateTo entries.
* fix(#603): delay drawer gesture activation to prevent residual swipe
* fix(#603): fix settings back navigation and drawer auto-open
---------
Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
Add a Language selector to Settings → Appearance that overrides the app
display language independent of the device locale. Persists a BCP-47 code
in ServerStoreState.appLanguage; MainActivity.attachBaseContext applies it
via a ContextWrapper (works on minSdk 26, no AppCompat dependency needed
since the Activity is a plain ComponentActivity). Selecting a language
recreates the activity so resources re-resolve immediately.
Includes System default / English / 한국어 options, plus a unit test for
the locale-code resolver.
Note: the new string keys (settings_item_language, language_system,
language_english, language_korean) should be backfilled into values-ko
once PR #602 lands.
Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
Allow the chat input to be sent while the agent is mid-turn or awaiting
approval. The backend's prompt.submit busy-input policy already queues any
prompt that lands during a running turn (tui_gateway/server.py:_handle_busy_submit)
and drains it as the next turn, so the message is never dropped.
- ChatInputPolicy: pure, testable send/placeholder decision logic
- ChatScreen: canSend stays enabled while typing/approving; placeholder shows
'Queued — sends when agent is free' when text is present during a busy turn
- ChatInputPolicyTest: truth-table covering typing, approval-pending,
disconnected, empty, attachment, and slash-command cases
- strings: chat_input_placeholder_queue
Note: this supersedes the Genesi599 fork's approach (dispatchViaRpc('queue ...')),
which double-submits and relies on a 4018-prone command.dispatch path. The
backend-queued prompt.submit path is the correct, single-submission contract.
Co-authored-by: M57 <m57@hermes-control.app>
* fix(#589): show in-session model picker when user sends /model
Typing a bare '/model' in the chat composer now opens the model picker so
the user can hot-swap the CURRENT session's model via the existing
slash-command path, instead of hand-typing the provider/model string.
- ChatViewModel.preloadModelOptions() caches GET /api/model/options at
GatewayReady so the picker opens instantly; falls back to on-demand
refresh if opened before preload finishes.
- sendMessage() intercepts a bare '/model' (no arg) -> showModelPicker=true
instead of dispatching; a typed '/model provider/model' still dispatches
via handleSlashCommand.
- sendSlashModel() routes through command.dispatch with session_id, so the
swap is session-scoped (not the global POST /api/model/set).
- Extracted ModelPickerDialog into ui/model/components and reused by both
ModelScreen and ChatScreen (adds loading + empty states).
- Added a top-bar model chip (current session model label) that opens the
same picker.
- Added unit tests: bare /model opens picker, picker selection hot-swaps via
slash dispatch with session_id, typed /model with arg dispatches directly.
Note: CI shows 2 pre-existing test failures (ChatViewModelTest.testToggleSearch,
HermesWsClientTest.testAutoReconnect) that also fail on a clean main full
suite (MockK static-mock leakage between classes). Unrelated to this change;
compile + ktlint + the 3 new tests pass.
* fix(#589): use backend-valid session-scoped /model command syntax
The model picker now dispatches the canonical backend command form
'/model <model> --provider <slug> --session' instead of the unsupported
'provider/model' slash form. The '--session' flag keeps the switch scoped
to the current chat only (per-session override) and never writes the global
model config, which matches the backend model-switch contract.
- sendSlashModel builds '/model $model --provider $provider --session'.
- Updated unit tests to assert the corrected arg payload
('gpt-4o --provider openai --session') and the fully-typed dispatch path.
* fix(#589): add pinned-models section to in-session model picker
- ModelPickerDialog now renders a Pinned section (mirrors global model screen)
- search filter matches model names too, not just provider name/slug
- ChatUiState gains modelPickerPinned, fed from AuthManager.getPinnedModels()
- top-bar model chip removed; picker opens on /model only
- test mock for getPinnedModels() added
* fix(#589): route /model via prompt.submit instead of command.dispatch (resolves 4018)
/model is a real backend slash command handled in the backend's message
pipeline (_handle_message -> _handle_model_command), so it must arrive as
a normal prompt.submit message, not the command.dispatch RPC (which only
knows quick/plugin/bundle/skill commands and 4018s on /model).
- SlashCommandDispatcher: add SlashResult.ModelSwitch, map /model -> ModelSwitch
- ChatViewModel: handle ModelSwitch -> handleModelSwitch() sends via
WsMethods.PROMPT_SUBMIT with {session_id, text}
- Tests: rewrite the two /model tests to assert prompt.submit (previously
asserted the buggy command.dispatch path); add /model -> ModelSwitch test
Independent agy review: APPROVE, no blocking issues.
* fix(#589): route /model via slash.exec (correct transport; prompt.submit made LLM treat it as text)
Earlier attempt routed /model through prompt.submit, but the TUI gateway's
prompt.submit handler does NOT parse slash commands — it runs the text as a
normal agent turn, so the LLM received '/model ...' as a user message (the
exact reported bug). The correct RPC is slash.exec, which runs the command
through the slash worker and _mirror_slash_side_effects -> _apply_model_switch
to hot-swap the live session model. command.dispatch is also wrong (only knows
quick/plugin/bundle/skill commands -> 4018).
- WsMethods: add SLASH_EXEC = "slash.exec"
- ChatViewModel.handleModelSwitch: send slash.exec with {command, session_id}
(previously prompt.submit with {text}); corrected doc comment
- SlashCommandDispatcher: corrected comments (slash.exec, not prompt.submit)
- ChatViewModelTest: rewrite both /model tests to assert slash.exec with the
/model <model> --provider <slug> --session command string
- SlashCommandDispatcherTest: /model -> ModelSwitch already covered
Verified against hermes-agent tui_gateway/server.py: slash.exec (L13190) ->
worker.run + _mirror_slash_side_effects (L13299) -> _apply_model_switch (L13118);
parse_model_flags accepts --provider/--session (hermes_cli/model_switch.py:355).
Independent agy review of the prior attempt: APPROVE (the wrong-transport
assumption was the author's, not agy's).
* fix(#589): route /model via config.set key=model (slash.exec 4001'd; prompt.submit made LLM treat it as text)
Third transport attempt, now verified against the gateway source:
- prompt.submit: gateway runs text as a normal agent turn -> LLM sees '/model'
as a message (the original reported bug). WRONG.
- slash.exec: gateway returns 4001 'session not found'. The slash worker path
resolves the session differently / requires agent-built state the mobile
session isn't in at switch time. WRONG.
- config.set key=model: gateway (server.py config.set L10253) routes key=='model'
straight to _apply_model_switch using the SAME _sessions.get(session_id)
lookup that the working command.dispatch uses. CORRECT.
sendSlashModel still builds '/model <model> --provider <slug> --session';
parse_model_flags (hermes_cli/model_switch.py:355) extracts model/provider/
session from it. handleModelSwitch now sends config.set {key:model, value:
command, session_id}.
- WsMethods: add CONFIG_SET = 'config.set'
- ChatViewModel.handleModelSwitch: config.set instead of slash.exec
- SlashCommandDispatcher: comments updated (config.set, not slash.exec)
- ChatViewModelTest: both /model tests assert config.set key=model + command
- also fixes the MockK testToggleSearch flake by preloading byte-buddy agent
via JAVA_TOOL_OPTIONS locally (container blocks JVM self-attach)
* fix(#589): strip /model prefix before config.set key=model (5001 'model names can't have spaces')
config.set key=model passes the value DIRECTLY to parse_model_flags — it does
NOT strip a leading '/model' the way slash.exec / prompt.submit do. Sending
'/model <model> ...' made parse_model_flags treat '/model <model>' as the
model name (contains a space) -> 5001.
handleModelSwitch now strips the '/model' prefix and sends the bare spec
'<model> --provider <slug> --session', matching the TUI client's
modelValueForConfigSet (ui-tui session.ts:23) used by useMainApp.ts:123.
- ChatViewModel.handleModelSwitch: spec = command.removePrefix('/model').trim()
- ChatViewModelTest: assert bare spec 'gpt-4o --provider openai --session'
- comment updated
* fix(#589): strip /model prefix case-insensitively before config.set
SlashCommandDispatcher and isModelPickerCommand match /model
case-insensitively, but handleModelSwitch used removePrefix("/model")
(case-sensitive). A typed /MODEL ... forwarded the literal slash prefix
to config.set and the backend's parse_model_flags wouldn't recognize it
— silent hot-swap failure.
Use startsWith("/model", ignoreCase=true) + substring(6) so any casing
is stripped. Added a regression test asserting /MODEL does not forward a
leading slash.
* fix(test): inject searchDispatcher to fix MockKException in testToggleSearch
---------
Co-authored-by: M57 <m57@hermes-control.app>