daf33dcb4c8f7fdafc890acfffc2fbb04569f004
637
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e12a23befe |
fix: correct bulk session delete endpoint + surface deleted count (#653)
* 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>
|
||
|
|
a62bc7e16c |
refactor: remove device Pairing feature entirely (issue #645) (#650)
* refactor: remove device Pairing feature entirely (issue #645) - Delete Pairing.kt model + pairing API endpoints (get/approve/revoke/clear) - Delete PairingScreen/PairingViewModel + PairingCodeEntryScreen/ViewModel - Remove NavKeys, ScreenRegistry drawer entry, Navigation.kt entry + Landing pairing button - Remove pairing string resources (en + ko); keep connect_* config-parse strings - Keep Telegram onboarding pairing_id flow (separate concept, per issue) - Fix related doc comments + remaining test reference * test: fix racy CONNECTED read in gated-mode WS test (issue #635 test) testGatedMode_attemptsTicketFetchWithoutBareCookie asserted CONNECTED immediately after the server-side onOpen latch fired, racing the client handshake (WsListenerImpl sets CONNECTED on receipt of the 101). Await the real status transition via connectionStatus.first{==CONNECTED} like the other connect tests do. No product behavior changed. --------- Co-authored-by: M57 <Hy4ri@users.noreply.github.com> |
||
|
|
e44a53366b |
Fix #647: resolve Default profile URL from top-level login URL (#649)
* 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> |
||
|
|
c8a5055a84 |
fix(auth): support __Host-/__Secure- prefixed session cookies and proxy-relative paths (issue #635) (#648)
- 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> |
||
|
|
86c780c10b |
docs: sync AGENTS.md, README.md, and CONTRIBUTING.md with current codebase (#644)
* docs: update AGENTS.md, README.md, and CONTRIBUTING.md Sync all three docs with current codebase state: Kotlin 2.4.10, Kotlinx Serialization, 28 screens, 6 theme presets, CI instrumented-tests job, color guard. * docs: missed control |
||
|
|
e18b25d065 |
fix(ux): keep Settings highlighted in bottom nav on drill-down sub-pages (issue #637) (#643)
* 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> |
||
|
|
367d6e22f5 | fix: language selector | ||
|
|
040fefdd4b |
security: disable Android Auto Backup (issue #636) (#642)
Set android:allowBackup="false" to stop the OS from snapshotting /data/data/com.m57.hermescontrol/ (server_store.json, SQLCipher DB, EncryptedSharedPreferences tokens/cookies, PersistentCookieJar) into the user's Google Drive. Removes a credential + chat-history exfil surface via Google account compromise. Co-authored-by: M57 <Hy4ri@users.noreply.github.com> |
||
|
|
88a068b204 |
Remove redundant logout button from Settings About tab (#641)
Co-authored-by: M57 <Hy4ri@users.noreply.github.com> |
||
|
|
26b104b2bb |
[feature] issue #634: HTTPS + reverse-proxy server endpoint support (#638)
* [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> |
||
|
|
d839c5e9ad |
fix(billing): align SubscriptionCurrent + SubscriptionTier to backend wire (issue #631) (#632)
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>
|
||
|
|
3363c1e586 |
perf(analytics): preload on launch + split-fetch + disk cache (issue #537) (#630)
* 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> |
||
|
|
00c0ddfb92 |
feat(billing): adopt subscription.* + usage.bars WS methods (issue #628) (#629)
* 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> |
||
|
|
6a421d7a84 |
feat(ux): add CTAs to EmptyState across screens (issue #618) (#627)
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> |
||
|
|
9661246497 |
audit(#622): sweep hardcoded colors + add checkColorLiterals guard (#626)
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> |
||
|
|
0018e5d172 |
feat(#617): skeleton loaders instead of bare spinner on list screens (#625)
Adds SkeletonListState to StateViews.kt — an animated shimmer placeholder list whose geometry matches a typical list row (avatar circle + title line + subtitle line). The shimmer colors derive from MaterialTheme.colorScheme.surfaceVariant (theme-aware, works on light/dark/AMOLED/Material You). Sweeps horizontally via an infiniteTransition tween — no new dependencies, pure Compose foundation animations. Wired SkeletonListState into 22 list-loading call sites across 22 screens: skills, cron, plugins, mcp, toolsets, webhooks, system, sessions (x2), config (x2 — list + YAMLEditor), model, profiles, keys, channels, achievements, gateways, kanban, logs, analytics, providers, processes, hub skills tab, installed skills tab (list branch). Preserved LoadingState() (centered spinner) where the issue explicitly calls for it: - PairingScreen (one-shot auth flow) - SkillEditorDialog / ModelPickerDialog / CronJobEditorDialog interiors - SkillPreviewDialog and SkillEditorDialog inline-loading branches in SkillsScreen.kt (lines 790, 914) - InstalledSkillsTab dialog-interior branch (line 393) SkeletonListState testTag: "skeleton_list_state". Verified: ktlintCheck, assembleDebug, testDebugUnitTest all green locally. Co-authored-by: Hy4ri <hy4ri@users.noreply.github.com> |
||
|
|
0a0b612d3a |
refactor(#619): kill DRAWER_GESTURE_SCREENS — screen-owned drawer gestures via HermesScaffold (#624)
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> |
||
|
|
0c2b9679b7 |
refactor(#621): split ChatScreen.kt god file into focused composable components (#623)
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> |
||
|
|
d484d535ce |
fix(#613): remove 77 unused string resources (#616)
Independently verified dead weight: computed defined vs used set across all source sets (R.string.* in Kotlin + @string/* in XML/manifest). Zero references in app/src/main, androidTest, or test fixtures. - values/strings.xml: -77 unused <string> entries (818 -> 741) - values-ko/strings.xml: -67 corresponding entries (704 -> 637) Build: ./gradlew assembleDebug BUILD SUCCESSFUL (no missing-resource errors). True KO translation gap after prune = 741 used EN - 637 translated KO = 104 (previously mis-stated as 114; the 67 removed were never translated anyway). Co-authored-by: M57 <hy4ri@users.noreply.github.com> |
||
|
|
dc1d5f2c7c |
fix(#614): implement ToastHost on Achievements/Sessions/Processes ViewModels (#615)
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> |
||
|
|
3030e431f2 |
feat: auto-scroll long chat title once on load + on tap (#612)
* 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> |
||
|
|
228431d57a |
[enhancement] feat(#606): support auth mode + bearer token when adding HTTP MCP servers (#607)
* 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 |
||
|
|
45490c82c2 |
chore: bump com.google.devtools.ksp from 2.3.9 to 2.3.10 (#611)
Bumps [com.google.devtools.ksp](https://github.com/google/ksp) from 2.3.9 to 2.3.10. - [Release notes](https://github.com/google/ksp/releases) - [Commits](https://github.com/google/ksp/compare/2.3.9...2.3.10) --- updated-dependencies: - dependency-name: com.google.devtools.ksp dependency-version: 2.3.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
0fc5b0d2ef |
chore: bump kotlin from 2.4.0 to 2.4.10 (#610)
Bumps `kotlin` from 2.4.0 to 2.4.10. Updates `org.jetbrains.kotlin.plugin.compose` from 2.4.0 to 2.4.10 - [Release notes](https://github.com/JetBrains/kotlin/releases) - [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md) - [Commits](https://github.com/JetBrains/kotlin/compare/v2.4.0...v2.4.10) Updates `org.jetbrains.kotlin.plugin.serialization` from 2.4.0 to 2.4.10 - [Release notes](https://github.com/JetBrains/kotlin/releases) - [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md) - [Commits](https://github.com/JetBrains/kotlin/compare/v2.4.0...v2.4.10) --- updated-dependencies: - dependency-name: org.jetbrains.kotlin.plugin.compose dependency-version: 2.4.10 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.jetbrains.kotlin.plugin.serialization dependency-version: 2.4.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
2195a3516d |
chore: bump junit5 from 6.1.1 to 6.1.2 (#609)
Bumps `junit5` from 6.1.1 to 6.1.2. Updates `org.junit.jupiter:junit-jupiter-api` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `org.junit.jupiter:junit-jupiter-engine` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `org.junit.vintage:junit-vintage-engine` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-api dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.junit.jupiter:junit-jupiter-engine dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.junit.vintage:junit-vintage-engine dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
650230b644 |
chore: bump org.junit.platform:junit-platform-launcher (#608)
Bumps [org.junit.platform:junit-platform-launcher](https://github.com/junit-team/junit-framework) from 6.1.1 to 6.1.2. - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) --- updated-dependencies: - dependency-name: org.junit.platform:junit-platform-launcher dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
3e357594be |
refactor(settings): drill-down navigation replacing tabbed layout (#605)
* 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> |
||
|
|
b3765bd106 |
feat: in-app display language switcher (Settings → Appearance) (#604)
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> |
||
|
|
4314bddaae |
Add Korean translation (values-ko/strings.xml) (#602)
* Add Korean translation (values-ko/strings.xml) * Disable MissingTranslation lint check |
||
|
|
d9f20f8eb2 |
fix(#576): surface command.dispatch 4018 errors instead of silent dead-tap (#603)
* fix(#576): surface command.dispatch 4018 errors instead of silent dead-tap * fix(#576): fall back to slash.exec for command.dispatch 4018 registry misses * fix(#576): block cli_only/TUI-only commands at dispatch (blocklist deliverable) * review(#603): address agy findings — tighten 4018 match, case-insensitive filter, add double-fault + blank-output tests * fix(#576): surface command dispatch outputs and update tests --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
e273488adc |
fix: add breathing room between rendered markdown paragraphs (2dp -> 6dp) (#601)
Co-authored-by: m57 <m57@357357357.xyz> |
||
|
|
0823fe3117 |
fix(#599): place cursor at end of text when a slash command is inserted from the dropdown (#600)
Co-authored-by: hy4ri <hy4ri@users.noreply.github.com> |
||
|
|
5376d46683 |
feat: queue prompts while the agent is running (#598)
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>
|
||
|
|
d1fc477bcd |
fix: resolve all build compilation and style check warnings (#597)
* fix: resolve all build compilation and style check warnings * fix: remove unused import in SystemViewModel |
||
|
|
48f5ffbe52 |
fix(#589): show in-session model picker when user sends /model (#596)
* 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> |
||
|
|
c543eb6470 |
feat(#530): server-backed session search (FTS5) (#555)
* feat(#530): add SessionSearch models + searchSessions endpoint + contract tests * feat(#530): wire server-backed search into Sessions screen (debounce + render) * style(#530): ktlint import ordering on HermesApiService * fix(#530): ktlint-clean overlap wrap + map search snippet to title * fix(#530): map search snippet to title so results show + highlight * fix(#530): render search hits as honest result cards instead of faking title Backend SessionSearchResult carries no session name (only snippet + metadata), so mapping snippet->title made the card show raw excerpt as the title. Instead, render a dedicated SearchResultCard: 'Match' label + session id, highlighted snippet as body, and source/model/played-at chips. Added SessionInfo.model and a friendly relative-time formatter. ktlint clean, compileDebugKotlin + unit tests pass. * fix(#530): strip JSON from search snippet + tighten result card padding * fix(#530): strip FTS highlight markers from search snippet on client agy's backend change re-wraps matched terms in >>>term<<< markers inside the clean snippet, but the mobile highlights by raw query term and never consumed those markers — so literal '>>>'/'<<<' would render in result text. Strip the markers before passing the snippet to highlightText so the query highlight still works on clean text. * fix(#530): integrate tree-flattening hierarchy with search modes in SessionsScreen * fix(#530): fix test assertion and mock ApiClient in SessionsViewModelTest * fix(#530): resolve compose PaddingValues compile error and fix slash-preservation test path * fix(#530): clean search snippet from JSON payload and add unit test --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
71c1bdded9 |
fix(#585): render terminal tool output via real backend payload keys (#595)
The backend terminal tool (tools/terminal_tool.py) returns a single merged 'output' string plus 'exit_code' and 'error' — never 'stdout'/'stderr'. ChatBubble was reading the non-existent 'stdout' key, so command output was silently dropped and only the Duration footer showed. - Parse 'output' as canonical; keep legacy 'stdout'/'stderr' fallback for backward compat. - Route terminal output into mainOutput so it renders like other tool bubbles. - Surface non-zero exit codes as an error line when no 'error' string is set. - Remove dead 'stderr' render branch. - Update ToolBubbleParsingTest to assert the real payload shape (incl. the exact issue-585 'echo hellp' sample) and add regression cases. Co-authored-by: hy4ri <hy4ri@users.noreply.github.com> |
||
|
|
47258bd8b0 |
refactor(theme): split per-preset color schemes into theme/presets/ (#594)
* refactor(theme): split per-preset color schemes into theme/presets/ Move each ThemePreset's ColorScheme + status colors into its own file under theme/presets/ (Default, Monochrome, Gruvbox, Catppuccin, Amoled, NeonNoir). Theme.kt becomes a thin dispatcher via resolveColorScheme/resolveStatusColors, making individual themes far easier to create and edit. - AMOLED keeps a dark-only scheme (falls back to default light) - MONOCHROME/AMOLED reuse default status colors (no bespoke semantic set) - aligned to current Voltage Purple palette + on* status-color params Verified: ktlint clean, compileDebugKotlin passes. * refactor(theme): make all preset files uniform 4-token shape Every preset file now exports XxxDarkColorScheme / XxxLightColorScheme / XxxDarkStatusColors / XxxLightStatusColors so they share one shape and import order. MONOCHROME + AMOLED alias the default status colors; AMOLED also gets an explicit AmoledLightColorScheme = DefaultLightColorScheme alias. Theme.kt dispatcher is now a pure lookup (no special-casing) for both color scheme and status colors. No runtime behavior change — pure structural cleanup. Verified: ktlint clean, compileDebugKotlin passes. * docs(theme): add THEMES.md documenting the per-preset convention Co-located in theme/ — covers layout, the uniform 4-token shape, the pure lookup dispatcher, why DEFAULT uses design tokens (intentional, not a bug), and a step-by-step 'add a new theme' recipe. Verified: ktlint clean. --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
29b630a2ec |
fix(#586): route hardcoded colors through theme tokens (Material You / presets) (#592)
Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
1870e4bb01 |
fix(#584): keep scroll free during streaming (no auto-follow) (#591)
* fix(#584): follow stream without freezing (persistent snapshotFlow) The auto-scroll LaunchedEffect was keyed on (messages.size, streamingMessage?.content?.length, isThinking) — those change on EVERY streamed token, so the effect restarted per token and cancelled the in-flight animateScrollToItem, leaving the view stuck/frozen mid-stream. Replace it with a single persistent LaunchedEffect(Unit) + snapshotFlow + collectLatest. The streaming follow now uses an instant scrollToBottom (animated=false) which is idempotent per token and has no animation to cancel; a discrete new message arriving while at the bottom still animates. Because the effect runs once, mirror the params through rememberUpdatedState and read them live inside snapshotFlow/collectLatest so the flow actually fires per token and session-switch detection uses the current session id (rather than stale first-composition closure captures). * fix(#584): leave scroll free during streaming (no auto-follow) m57 follow-up: the previous fix stopped the freeze but still force-pinned to the bottom while streaming. Now, while a message is streaming, the scroll is left completely free — no auto-follow of the streaming message — so the user can scroll up/down to read while it generates. Auto-scroll only on explicit actions (send / FAB / session switch) and when a discrete non-streaming message lands while already pinned to the bottom. streamingMessage is dropped from the snapshotFlow keys so the flow no longer churns per token. --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
e5e4231183 |
fix(#583): scroll-to-bottom aligns last message bottom to viewport (#590)
* fix(#583): align last message bottom to viewport on scroll-to-bottom animateScrollToItem(lastIndex) only top-aligns the last item, so when the last message is taller than the viewport its bottom is clipped below the fold. Replace the 4 scroll-to-bottom call sites (send auto-scroll, session-switch, auto-scroll-on-new-message, and the FAB) with a scrollToBottom() helper that top-aligns then scrolls the exact remaining delta so the last item's bottom edge is visible. Using the remaining delta (not scrollOffset=Int.MAX_VALUE) avoids integer overflow in the internal scroll-position clamp, which would wrap the offset to 0 and scroll back to the top for tall items. * fix(#583): keep smooth animation on scroll-to-bottom when animated agy review (PR #590) flagged that the animated path did an instant scrollToItem then only animated the small remaining delta, causing a hard jump on FAB / send clicks instead of a smooth scroll. Use animateScrollToItem when animated, scrollToItem when instant. --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
c446715608 |
fix(#587): forward session_id on image.attach_bytes and await the RPC (#588)
* fix(#587): forward session_id on image.attach_bytes and await the RPC The mobile image-send path called image.attach_bytes without the required session_id field, so the gateway resolver (_sess) returned JsonRpcError(code=4001, message=session not found). - Add "session_id" to agentSessionId to the image.attach_bytes params (desktop reference client already does this; mobile was the outlier). - Switch the image branch from fire-and-forget wsClient.send to the awaited sendRpcAndAwait() layer (same awaited-RPC path used for slash commands, #526). This also removes the race where prompt.submit could fire before the image was staged, and surfaces RPC errors instead of silently swallowing them. Root cause verified against tui_gateway/server.py _sess() and the desktop use-prompt-actions reference. ChatViewModel.kt passes ktlintCheck. Closes #587 * fix(#587): also forward session_id on file.attach (same 4001 bug) The image branch was fixed in the prior commit, but the file branch (sendMessage else -> file.attach) omitted session_id too, so non-image file uploads also hit JsonRpcError(code=4001, "session not found"). The backend file.attach handler (tui_gateway/server.py _sess) requires session_id exactly like image.attach_bytes. Add "session_id" to agentSessionId to the file.attach params, and refresh the sendMessage flow comment (no longer fire-and-forget). Verified by source: both image.attach_bytes and file.attach call the same _sess resolver. Camera capture is unaffected (it produces an image/jpeg attachment, which already routes through the fixed image.attach_bytes branch). ChatViewModel.kt passes ktlintCheck. * test(#587): regression for image.attach_bytes session_id Adds testSendMessage_imageAttachment_sendsSessionId, which proves the image-attach RPC is issued with session_id (the missing field that caused JsonRpcError 4001 'session not found'). Root blocker found: readContentUriBase64 constructs android.util.Base64OutputStream, which throws MockK's 'Stub!' on the JVM test, making the read return null and the image branch never run. Mocking the Base64OutputStream constructor lets the read succeed so the request(any(), capture(paramsSlot), any()) stub captures the params and the session_id assertion holds. Full ChatViewModelTest suite passes; ktlint-clean. --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
173820c304 |
fix(#574): hide desktop/CLI-only slash commands from mobile suggestions (#582)
* fix(#575): drop description text from slash-command suggestions * fix(#575): show /stop and /interrupt in slash-command menu * fix(#574): hide desktop/CLI-only slash commands from mobile suggestion menu Recreate hiddenSlashDisplay blocklist removed in 1f322e3 with the 33 cli_only/TUI-only commands that don't function on mobile (issue #574). /stop and /interrupt intentionally remain visible (they work on mobile, #575). --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com> Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
955511a89f |
[enhancement] fix(#575): drop description text from slash-command suggestions (#581)
* fix(#575): drop description text from slash-command suggestions * fix(#575): show /stop and /interrupt in slash-command menu --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com> |
||
|
|
e48778291a |
fix: clarify Dismiss now informs the agent instead of silently dropping (#580)
* fix(#572): clarify Dismiss now informs agent instead of silently dropping * fix(#572): clarify Dismiss rejects the question (no answer) instead of telling agent to proceed --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
109fb1a04d |
fix(#571): make clarify dialog scrollable so long questions/options aren't clipped (#579)
Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
594255d534 |
fix(#572): render Markdown in assistant chat bubbles after streaming (#577)
* fix(#572): render Markdown in assistant chat bubbles after streaming - Add MarkdownText composable: block-aware parser (headings, lists, blockquotes, fenced code, inline code, bold, italic, links) - Gate formatting on message.isStreaming: plain text during streaming, formatted view swapped in on completion (kills flicker/re-parse churn) - Fenced code blocks render as a scrollable card with a copy button - Remove dead RichText; wire AssistantBubble to MarkdownText - ktlintCheck + compileDebugKotlin + testDebugUnitTest green * fix(#572): address agy review — memoize inline parse, drop nested SelectionContainers, fix heading detection, reset copy state * fix(#572): expand Markdown support — tables, strike, bold+italic, task lists, hr, footnotes, def-lists, highlight, sup/sub, kbd (math excluded) * fix(#572): address agy review — fix table alignment (textAlign) + quote bar height (IntrinsicSize), fix ktlint line length * fix(#572): resolve infinite loop hang on hashtag refs in markdown parser --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
8d22988985 |
fix: shorten chat banner status text + prevent relogin button squish (#570)
* fix: shorten chat banner status text + prevent relogin button squish * fix: drop bad weight import (resolve internal RowColumnParentData conflict) --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
907d247312 |
fix: refresh loopback dashboard token after restart (#568)
* fix: refresh loopback dashboard token after restart The gateway regenerates its ephemeral session token on every restart, so the token the app persisted becomes stale. After a dashboard restart REST calls 401 and the WebSocket auto-reconnect loop hangs in AUTH_EXPIRED until the user manually re-authenticates. - Add DashboardSessionTokenRefresher: re-reads the injected window.__HERMES_SESSION_TOKEN__ from the dashboard SPA and writes it back to AuthManager. - TokenRefreshAuthenticator: on a 401 retry, scrape a fresh token from the dashboard and retry instead of giving up. - HermesWsClient: refresh the token before each non-gated WebSocket handshake so reconnects don't get stuck on a stale token; guard serverStore read against IllegalStateException. Covers the loopback (--insecure) deployment path; gated/ticket mode is untouched. * fix: harden dashboard token refresher (sync, body cap, blank-token test) Second-opinion review flagged robustness gaps (no correctness/security regressions in the happy path): - DashboardSessionTokenRefresher.refresh(): @Synchronized to dedupe concurrent 401 / reconnect storms instead of each firing its own GET. - Cap SPA HTML read at 64 KiB before regex scan (defensive against OOM on low-end devices); we only need the injected token near the document top. - HermesWsClient: log (not silently swallow) the transient uninitialized serverStore case so a real misconfig is still visible. - Add test: blank-injected token returns null. --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
ee28ee5ce2 |
feat(#538): wire tool.progress/generating + subagent.* events into chat (#567)
* feat(#538): wire tool.progress/generating + subagent.* events into chat * fix(#538): remove subagent indicator on subagent.complete so it doesn't linger * style(#538): drop dead Elvis on non-nullable event.text in onMessageComplete --------- Co-authored-by: M57 <m57@hermes-control.app> |