Commit Graph
494 Commits
Author SHA1 Message Date
M57 58420d7b2a fix(theme): complete M3 tokens and monochrome status colors (#681) 2026-07-22 15:31:09 +03:00
M57 7854c26365 feat(navigation): regroup Kanban to AUTOMATE and Gateway to INSPECT in navigation drawer (#679)
* feat(navigation): regroup Kanban to AUTOMATE and Gateway to INSPECT in navigation drawer

* feat(navigation): change Achievements screen icon to EmojiEvents (trophy)
2026-07-22 15:11:55 +03:00
M57andM57 1cca7764cc perf: optimize data loading across Config, System, Profiles, and Billing tabs (#678)
* 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>
2026-07-22 15:03:30 +03:00
M57 f7afbfae85 perf(model): optimize model tab load speed with two-phase fetch and loadQuick (#677) 2026-07-22 14:46:38 +03:00
M57andM57 0bb418b4a7 ui: input bar reasoning chip tweaks (#673)
* 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>
2026-07-22 14:21:06 +03:00
M57andM57 56de3323f0 fix(chat): disable unusable history pagination (#676)
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>
2026-07-22 12:09:29 +03:00
M57andM57 0edb67338d refactor: remove bottom navigation bar (#665 #666 #667 #668) (#672)
* refactor(nav): remove bottom NavigationBar from Scaffold (#665)

* refactor(theme): remove BottomNavDisplayMode enum (#666)

* refactor(settings): remove nav bar customization page (#667)

* chore: clean up unused imports after bottom nav removal (#668)

* chore: remove dead bottom nav infrastructure after removal

- Remove SettingsNavBar NavKey (NavigationKeys.kt)
- Remove bottomNavItems field from ServerStoreState + migration code
- Remove _bottomNavItemsFlow, getBottomNavItems, setBottomNavItems from AuthManager
- Remove bottomNavItems handling from selfHealed() (ServerCollectionOps)
- Remove redundant bottomBar={} from Scaffold (Navigation.kt)
- Remove unused nav bar strings from values/strings.xml + values-ko/strings.xml
- Remove content_desc_move_up/down/remove_item strings
- Update SettingsViewModelTest.kt (drop stale getBottomNavItems mock)
- Update ServerStoreTest.kt (remove bottomNavItems assertions)

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-22 10:14:50 +03:00
M57andM57 c43904b2b8 feat(chat): rich message bubbles with reasoning, code, clarify, subagent cards (#659 #660 #661 #662 #663 #664) (#671)
* 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>
2026-07-22 02:04:55 +03:00
M57andM57 bb4149eb0d refactor(chat): two-row ChatComposer + model/reasoning chips (#669)
* 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>
2026-07-21 18:18:19 +03:00
M57 c16f6e3e0b fix(chat): make chat input composer more compact and flush (#654)
* 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
2026-07-21 12:52:04 +03:00
M57andM57 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>
2026-07-21 12:43:06 +03:00
M57andM57 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>
2026-07-20 23:23:29 +03:00
M57andM57 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>
2026-07-20 22:03:31 +03:00
M57andM57 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>
2026-07-20 20:56:39 +03:00
M57andM57 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>
2026-07-20 18:26:05 +03:00
M57andM57 88a068b204 Remove redundant logout button from Settings About tab (#641)
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-20 17:43:49 +03:00
M57andhy4ri 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>
2026-07-20 17:26:10 +03:00
M57andronia 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>
2026-07-19 22:25:42 +03:00
M57andM57 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>
2026-07-19 20:41:23 +03:00
M57andM57 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>
2026-07-19 15:06:09 +03:00
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>
2026-07-18 16:42:32 +03:00
M57andHy4ri 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>
2026-07-18 16:15:45 +03:00
M57andHy4ri 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>
2026-07-18 15:12:21 +03:00
M57andHy4ri 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>
2026-07-18 14:25:23 +03:00
M57andM57 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>
2026-07-18 13:03:04 +03:00
M57andM57 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>
2026-07-17 16:59:05 +03:00
M57andM57 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>
2026-07-16 21:45:13 +03:00
M57 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
2026-07-16 13:58:20 +03:00
M57andhy4ri 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>
2026-07-16 01:23:19 +03:00
M57andhy4ri 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>
2026-07-15 23:57:39 +03:00
M57andM57 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>
2026-07-15 23:43:00 +03:00
M57andm57 e273488adc fix: add breathing room between rendered markdown paragraphs (2dp -> 6dp) (#601)
Co-authored-by: m57 <m57@357357357.xyz>
2026-07-15 12:59:04 +03:00
M57andhy4ri 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>
2026-07-15 10:36:18 +03:00
M57andM57 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>
2026-07-15 01:46:29 +03:00
M57 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
2026-07-14 21:55:37 +03:00
M57andM57 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>
2026-07-14 20:39:33 +03:00
M57andM57 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>
2026-07-14 12:47:59 +03:00
M57andhy4ri 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>
2026-07-14 12:01:08 +03:00
M57andM57 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>
2026-07-14 11:09:02 +03:00
M57andM57 29b630a2ec fix(#586): route hardcoded colors through theme tokens (Material You / presets) (#592)
Co-authored-by: M57 <m57@hermes-control.app>
2026-07-14 00:57:12 +03:00
M57andM57 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>
2026-07-14 00:04:17 +03:00
M57andM57 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>
2026-07-13 23:13:30 +03:00
M57andM57 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>
2026-07-13 22:40:16 +03:00
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>
2026-07-13 19:51:40 +03:00
M57andM57 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>
2026-07-13 19:20:28 +03:00
M57andM57 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>
2026-07-13 18:53:09 +03:00
M57andM57 109fb1a04d fix(#571): make clarify dialog scrollable so long questions/options aren't clipped (#579)
Co-authored-by: M57 <m57@hermes-control.app>
2026-07-13 18:15:26 +03:00
M57andM57 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>
2026-07-13 17:24:59 +03:00
M57andM57 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>
2026-07-13 11:22:10 +03:00
M57andM57 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>
2026-07-13 00:58:58 +03:00