* fix(network): scope management REST calls with ?profile= (fixes#528)
Mobile fired all management REST calls with NO profile scope, so every
profile read/wrote the backend's implicit default profile — multi-profile
setups silently corrupted config/skills/toolsets/mcp/model/env.
Add ProfileScopeInterceptor (OkHttp) that appends ?profile=<activeProfile>
to the same endpoint families the desktop/web dashboard scopes
(web/src/lib/api.ts PROFILE_SCOPED_PREFIXES), respecting:
- no active profile -> pass through (legacy default behavior)
- explicit ?profile= on the URL wins over the global scope
- only scoped families rewritten; ops/pairing/cron/profiles stay global
Wired into ApiClient.buildService(). 5 MockWebServer tests cover scoped
append, explicit-param precedence, no-profile passthrough, non-scoped skip,
and skills scoping.
Mirrors desktop/web contract derived from audit of /opt/hermes-agent.
* fix(ci): stub getSelectedProfileId in ApiClientTest + harden prefix match (fixes PR #540)
- ApiClientTest: stub AuthManager.getSelectedProfileId() to null. The new
ProfileScopeInterceptor (registered in ApiClient.buildService) calls it, and
the unstubbed real method threw IllegalStateException at AuthManager.kt:56,
breaking all 4 auth-interceptor tests.
- ProfileScopeInterceptor: replace startsWith(prefix) with whole-segment match
(path == prefix || path.startsWith(prefix/)) so lookalike paths like
/api/statusXYZ are NOT wrongly scoped (Sourcery review, PR #540).
- ProfileScopeInterceptorTest: add lookalikePath_notScoped + scopedSubPath_isScoped
to lock the segment-boundary fix.
Backend commit 37a4cf900 (hermes-agent) flipped the default of
GET /api/model/options's include_unconfigured parameter from true
to false, so the mobile Models picker was silently regressing to
showing only configured/authenticated providers — the skeleton rows
for unconfigured providers (with setup-hint warnings) stopped arriving.
Add include_unconfigured as a @Query param defaulting to true so the
full provider universe is restored on the Models screen. Both current
call sites (ModelViewModel + ProfilesViewModel) invoke the API without
passing the new param, so they automatically pick up the true default.
* fix(ws): stop chat opening from racing the singleton into a stuck RECONNECTING
Root cause: HermesWsClient is a global singleton connected once at login;
every tab observes its single connectionStatus. ChatViewModel is recreated
on every chat-tab open and its init calls connectWebSocket() -> wsClient.connect().
connect() only guarded the CONNECTED case, so when the socket had dropped
(singleton in RECONNECTING with its own scheduled reconnect) opening chat
spawned a SECOND socket on the same webSocket field. The orphaned socket's
later onClosed set connected=false + re-armed RECONNECTING after the new
socket connected, and currentBackoff kept resetting on each chat open,
leaving the status stuck on RECONNECTING while every other tab (which never
calls connect()) stayed healthy.
Fix:
- HermesWsClient.connect() now no-ops while CONNECTING/RECONNECTING (in-flight
socket already pending) and while AUTH_EXPIRED (needs re-auth, not a loop).
- ChatViewModel.connectWebSocket() mirrors the same terminal-state guard so
the chat tab never disturbs an already-working or already-recovering
connection. Behavior-preserving: happy path (CONNECTED) and genuine
terminal states (DISCONNECTED/NO_NETWORK) still connect as before.
* fix(#470): sync cookie store scope with selected profile ID
Update AuthManager to switch CookieManager to the active server profile ID on startup and on runtime profile change.
* fix(#470): prevent stale token cache and load initial cookie scope
Pass active profile scope to CookieManager.initialize on startup and update setProfileToken to sync the in-memory cachedToken.
* feat(chat): add re-login action and credentials popup to connection banner
Add a Re-login button to the connection status banner and a dialog to prompt for credentials, allowing the user to refresh session cookies/tokens and reconnect the WebSocket.
* fix(chat): address PR #495 review feedback
* fix(chat): restore reconnect action in banner
* fix: resolve composable invocation in ReloginDialog
* fix(chat): enable re-login action during reconnecting state
Add the Re-login button to the connection status banner when connectionStatus is RECONNECTING, allowing users to re-authenticate and resolve stuck reconnect loops.
Replace the manual session-cookie interceptor in ApiClient and the
manual cookie branch in TokenRefreshAuthenticator with a shared
PersistentCookieJar backed by an encrypted, per-server EncryptedCookieStore.
- Add CookieStore iface + EncryptedCookieStore (EncryptedSharedPreferences,
per-server scope, legacy single-cookie migration)
- Add CookieSerialization (OkHttp Cookie <-> holder) + PersistentCookieJar
(in-mem cache, async persist, atomic useStore scope switching, prune,
session-cookie accessor)
- Add CookieManager coordinator (init/useStore/prune/clear) and wire its
cookieJar into OkHttpProvider base/websocket/probe (lazy)
- AuthManager.get/setSessionCookie now delegate to CookieManager; add
pruneServerCache(); clear path drops the scope's session cookie
- AuthLoginViewModel + HermesWsClient rely on the jar for Set-Cookie capture
and session-cookie re-attachment (no manual header injection)
- Add fakes (FakeEncryptedCookieStore/buildFakePersistentCookieJar) + unit
tests (PersistentCookieJarTest, CookieManagerTest); inject fake jar into
ApiClientTest/HermesWsClientTest setup
WS token-out-of-URL (step 5) deferred: backend hermes-agent authenticates
WS only via ws.query_params, so moving it requires a coordinated backend
change (noted for follow-up).
Verified: testDebugUnitTest (334 passing) + assembleDebug green via local SDK.
* feat(#480): pass refresh=true on Models pull-to-refresh to re-probe custom providers
* test(#480): assert refresh flag propagation for default vs pull-to-refresh loads
* fix(#480): capture refresh arg via slot instead of brittle coVerify in propagation test
---------
Co-authored-by: ronia <ronia@m57>
* refactor: migrate Gson serialization to kotlinx.serialization
* fix(dto): resolve serializing Any? and Map<String, Any?> with JsonElement
* test(refactor): fix unit test issues under kotlinx.serialization and update nullable DTO defaults
* fix(chat): support flexible numeric and string decoding for message timestamps
* fix(chat): sequentialize cache loading to avoid overwriting network state
* fix(chat): support session IDs containing slashes in Retrofit endpoints
Set encoded = true on the @Path("id") annotations for all session endpoints
to prevent URL-encoding slashes, which caused 404 errors.
* chore: clean up inline comments — drop kanban metadata, align issue ref to #468
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix: correct FilterChipRow and EmptyState API mismatches + ktlint fmt
* fix: add missing Save import, fix verticalArrangement double-wrapping
* fix: address Sourcery review — strings, enums, hubQuery, StatusColors
• Hardcoded strings → stringResource (Installed, Browse Hub, Statuses, All sources)
• Magic string filters → SkillFilter enum + CATEGORY_ALL constant
• FilterChipRow made generic with chipLabel parameter
• HubBrowseView hubQuery driven from ViewModel state (no dupe)
• ViewModel toasts use getString(R.string.*) via AndroidViewModel
• MaterialTheme.colorScheme.error → LocalHermesStatusColors.current.error
* fix: OpenDrawer → Menu, listContentPadding is PaddingValues not ext
NavIcon.OpenDrawer doesn't exist, use NavIcon.Menu. listContentPadding is a val PaddingValues, not a Modifier extension — use Modifier.padding(listContentPadding).
* fix: NavIcon.Menu(onClick) -> NavIcon.Menu(onOpen)
* fix: pass mockApp to SkillsViewModel in unit tests (now AndroidViewModel)
* fix: mock getString in unit tests for AndroidViewModel string resources
* fix: anyVararg<Any?>() → anyVararg<Any>() to satisfy type bound
anyVararg<T> has bound T : Any, so Any? doesn't compile.
Any is a subtype of Any? at the JVM level, so the matcher
works correctly for vararg Any? parameters.
* fix: use anyVararg<String>() for getString mock to match actual runtime types
The previous anyVararg<Any>() did not match getString(Int, Object...) vararg
calls at runtime — mockk's relaxed mock returned '' for all formatted string
resources, causing all Skills error-path assertions to fail (9 tests).
Root cause: mockk vararg matchers match by reified element type. The Java
method Context.getString(int, Object...) passes format args as String at
runtime (via Kotlin's spread). anyVararg<Any>() with Any (boxed) doesn't
match Object/String vararg elements reliably, but anyVararg<String>() does,
since String satisfies the T : Any bound and matches the actual runtime type.
Also added getString(Int) stub for the no-arg overload used by enums.
* fix: stub getString per-resource-ID to bypass mockk Java vararg bug
mockk has a known bug matching Java vararg methods like
Context.getString(int, Object...) with anyVararg<T>() — the matcher
silently fails to match, and the relaxed mock returns '' for all
formatted string resource calls. This caused 8 Skills error-path tests
to fail (errorMessage/toastMessage was '' instead of containing 'HTTP 500').
Root cause: confirmed via test report artifacts — errorMessage was
non-null but empty string, proving the vararg stub never matched.
Fix: replace the generic anyVararg<String>() stub with explicit per-
resource-ID stubs using eq() + any<String>() matchers. Each formatted
string resource is stubbed individually, returning the format arg
(the dynamic error message) so the test assertions find 'HTTP 500'
and 'Timeout' substrings.
References:
- https://stackoverflow.com/questions/54326914/using-mockk-to-match-any-varargs
- https://github.com/mockk/mockk/issues/224
* fix(skills): replace AndroidViewModel getString() with hardcoded strings to fix unit tests
The SkillsViewModel was using getApplication<Application>().getString() for
error/success messages. In unit tests, mockk cannot reliably stub Java vararg
Context.getString(int, Object...), causing NullPointerException inside
coroutine onError callbacks. All other ViewModels in the codebase use hardcoded
strings; this change aligns SkillsViewModel with that convention.
* test: add hub search/install/uninstall/preview coverage + rm vestigial stubs
- Remove unused mockApp.getString() per-resource-ID stubs (SkillsViewModel
no longer calls getApplication().getString() — hardcoded strings now).
- Add 10 tests covering hub search (success/empty/error/emptyQuery),
hub install (success/error), hub uninstall (success/error), and skill
preview (success/error). Uses existing mockk + runTest patterns.
* fix: correct import ordering for HubSkill (H < S)
Move HubSkill import before KanbanBoard (H < K) and remove duplicate
that was between SessionListResponse and Skill.
* test: remove coVerify from emptyQuery test (cross-test mock state)
* fix: remove double-stacked padding in SkillsScreen (HermesScaffold handles it)
Per AGENTS.md — HermesScaffold already offsets for the top bar
internally, so .padding(padding) on inner content creases double-stacked
top padding.
* refactor: merge preview + edit button in SkillCard into single adaptive action
Per AGENTS.md — two nearly-identical icon buttons (Preview + Edit)
side by side was confusing. Merged into one Edit button since
both opened the same skill content dialog (editable for installed,
read-only for hub is handled by the ViewModel).
* fix: revert onCategoryChange to non-null String (FilterChipRow<T> needs it)
Also removes leftover Preview icon import that ktlint flagged.
* refactor: restructure SkillCard layout — name gets full width, actions below
The old layout crammed icon, name, source badge, edit button, uninstall
button, AND toggle switch all on one horizontal row. Long skill names
got truncated to 2-3 characters.
New layout:
- Top row: icon | name (weight 1f) | source badge | toggle switch
- Below: category (if present)
- Below: description (if present)
- Bottom row: edit + uninstall buttons (end-aligned, only if hub source)
The name Text now has weight(1f) so it fills all available space.
* fix: fix brace nesting + move icon to far left for extra name space
* fix: add missing height import
* refactor: remove Extension icon from SkillCard for cleaner layout
* fix: remove double-stacked padding in SkillEditorDialog and SkillPreviewDialog
Both dialogs had the AGENTS.md footgun: HermesScaffold already offsets
the top bar internally, but the inner Box had .padding(padding) stacking
an extra ~56dp gap between the header and the edit text field.
* fix(skills-hub): unwrap SkillHubSearchResponse wrapper
Backend changed /api/skills/hub/search from returning a raw JSON
array to an object wrapper: { results: [...], source_counts: {...}, ... }
- Added SkillHubSearchResponse, SkillHubInstalledEntry models
- Extended HubSkill with identifier, trustLevel, repo, tags
- Updated API endpoint return type to SkillHubSearchResponse
- ViewModel unwraps data?.results.orEmpty() instead of data.orEmpty()
* fix: unwrap SkillHubSearchResponse in ProfilesViewModel too + remove unused HubSkill import
* fix: update test mocks to use SkillHubSearchResponse wrapper
* fix: refactor HubSkillCard layout — remove icon, name gets full width, install below
* fix: use unique key for hub results + weight(1f) to prevent scroll crash
* fix(skills): resolve crash due to duplicate keys in lazy list
* feat(skills): move search hub button into trailing icon of search bar
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#442): categorized env keys screen with add/remove, search, and gateway restart
- Group env vars by category (LLM Providers, Tool API Keys, Messaging
Platforms, Agent Settings, Other) with collapsible AnimatedVisibility
sections matching MCP screen pattern
- Add new env var form (PUT /api/env) with key/value inputs
- Delete env var via DELETE /api/env with EnvVarDeleteRequest body
- Reveal/hide/edit inline per-card controls
- Search bar filtering across key names and descriptions
- Gateway restart banner with one-click action when changes are dirty
- Track keysChanged flag showing restart prompt after mutations
- Wire delete endpoint in HermesApiService using @HTTP(hasBody=true)
- Add EnvVarDeleteRequest data model class
* fix(#442): update test to match new categorized KeysViewModel API
* fix(#442): satisfy ktlint multiline-expression-wrapping rule
---------
Co-authored-by: Hermes Agent <hermes@agent.local>
* feat(#444): add MCP API endpoints and data models
- Add McpServer.status and McpServer.error fields for diagnostics
- Add AddMcpServerRequest, McpCatalogResponse/Entry/EnvVar models
- Add McpServerTestResponse model
- Add POST addMcpServer, PUT updateMcpServer, POST restartMcpServer
- Add GET getMcpCatalog, POST installMcpCatalogEntry
* feat(#444): add server form, enriched server cards, catalog section
- Add HTTP/stdio inline server form with tabs
- Enriched server cards with status badge, restart, env var editing
- Add/edit/remove environment variables per server
- Catalog section with search, result cards, install flow with env vars
- Status badge colors: running=green, error=red, stopped/gray
- Server diagnostics shown as error badges on cards
- New string resources for all MCP UI elements
* fix(#444): add missing McpServer import to HermesApiService
* fix(#444): cleanup review issues — dead code, spinner, unused strings
- Remove unused serverDiagnostics from UiState
- Remove unused showDiagnostics local state in ServerCard
- Remove unused McpServerTestResponse model
- Remove redundant refreshDiagnostics() alias
- Replace catalog loading text with CircularProgressIndicator
- Remove unused string resources (action_restarting, diagnostics, catalog_load)
* fix(#444): remove remaining showDiagnostics reference at line 348
---------
Co-authored-by: Hermes Agent <hermes@agent.local>
* feat(#434): add system tab model files
* feat(#434): add system API endpoints
* feat(#434): rewrite SystemViewModel with full parity
* feat(#434): add system screen UI and string resources
* feat(#434): update tests for new SystemStatsResponse & ViewModel
* fix(#434): fix compile errors — nullable Boolean, SmallFilledTonalButton -> FilledTonalButton
* fix(#434): fix old SystemStatsResponse refs in ChatViewModel, wrap sections in item{} blocks
* fix(#434): rename sections to lowercase, fix LazyListScope extensions
* fix(#434): wrap hook dialog in item{}, pass spacing param to actionLogSection
* fix(#434): update HermesApiServiceTest for new typed SystemStatsResponse
* fix(#434): fix E2E test coroutine timing, remove outdated Gson edge-case test
* fix(#434): fix JUnit 4 assertNotNull argument order
* fix(#434): simplify HermesApiServiceTest, restore all mocks in E2e test
* fix(#434): remove Gson-edge-case test, fix E2E test dispatcher wiring
- Removed testGetSystemStats_nonNumericPercent_returnsNull: tests Gson
type-coercion behavior, not app logic — throws JsonSyntaxException
- Fixed E2eIntegrationTest dispatcher: mock Dispatchers.IO with answers {}
that returns the current Dispatchers.Main dynamically, so runTest's
per-test dispatcher override is honored by both viewModelScope.launch
and async(Dispatchers.IO) blocks → advanceUntilIdle() advances all work
- Removed Dispatchers.Main static mock so runTest can override it
* fix(#434): fix E2E test dispatcher for SystemViewModel
- Removed Dispatchers.setMain + testDispatcher field from setUp
- runTest handles Dispatchers.Main automatically — don't override it
- Mock Dispatchers.IO with answers { Dispatchers.Main } so it
dynamically resolves to whatever Main is at call time (runTest's
TestScope dispatcher)
- This ensures viewModelScope.launch and async(Dispatchers.IO) both
run on the same TestScope dispatcher, making advanceUntilIdle()
effective for all work
* fix(#434): use runTest(testDispatcher) for SystemViewModel test
The SystemViewModel.loadAll() uses async(Dispatchers.IO) inside
coroutineScope { }. With the mocked Dispatchers setup, both Main and
IO return testDispatcher, but runTest()'s default TestScope uses a
different dispatcher instance whose advanceUntilIdle() can't advance
testDispatcher's scheduler.
Fix: pass testDispatcher to runTest() so the TestScope's scheduler
matches testDispatcher's scheduler, making advanceUntilIdle()
effective for all async work.
* fix(#434): align test assertion with toast message change
The SystemViewModel.triggerBackup() toast message was changed from
'Backup triggered successfully' to 'Backup triggered', but the
E2eIntegrationTest assertion wasn't updated, causing a
ComparisonFailure on the unit-tests CI job.
* fix(#434): address review issues
- Fix#1: Change triggerBackup() return type from Unit to ActionResponse
so backupArchive is captured, enabling the download button
- Fix#2: Replace deprecated LocalClipboardManager with LocalClipboard
suspend API (removes CI deprecation warning)
- Fix#3: Consolidate redundant 'Hermes version' InfoRow in gateway
section (sessions + PID shown in one row)
- Remove orphaned system_gateway_state_pid string resource
- Fix import ordering for ktlint compliance
* fix(#434): revert LocalClipboard change, use @Suppress('DEPRECATION') instead
LocalClipboard.setText is not available in the Compose version used
by the project. Reverting to LocalClipboardManager with a suppress
annotation to keep the deprecation warning clean without breaking
compilation.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes@agent.local>
* feat(#445): full Models tab — settings panel, aux tasks, MOA config, model assignment
Main features added:
- Model Settings card at top of screen with main model, aux tasks, MOA summary
- Main model assignment via /api/model/set with expensive model confirm flow
- Auxiliary tasks dialog with per-task model override and reset-all
- MOA config dialog with preset management and reference/aggregator model picker
- Model picker dialog reused for main model, aux tasks, and MOA slot selection
- Kept existing provider browse + pin/unpin/search
New data models: AuxiliaryModelsResponse, MoaConfigResponse,
ModelAssignmentRequest/Response
New API endpoints: GET /api/model/auxiliary, GET/PUT /api/model/moa,
POST /api/model/set
* test(#445): add aux + MOA mock endpoints to E2eIntegrationTest
testModelOptionsSelection_success was failing because the ViewModel's init
block now calls getAuxiliaryModels() and getMoaConfig() which weren't mocked.
Added proper mock returns + imports for the new types.
* fix(#445): correct mock endpoint name in E2eIntegrationTest
- getMoaConfig -> getMoaModels (matches API service method name)
- Add explicit type parameter Response.success<MoaConfigResponse>
to fix Kotlin type inference
* fix(#445): add missing main param to AuxiliaryModelsResponse mock
AuxiliaryModelsResponse requires a MainModelAssignment for its
'main' field. Added the parameter with a dummy value and the
corresponding import.
* fix(#445): update toast assertion in test to match new message format
ViewModel now produces 'Successfully set profile model to ronia'
instead of the old 'Successfully set model to ronia'.
* fix(#445): remove top padding from Models tab LazyColumn
Top inset already provided by HermesScaffold; double padding was chunky next to TopAppBar.
* fix(#445): remove double-stacked top padding on Models tab LazyColumn
HermesScaffold's Box already applies padding(paddingValues) to
offset content below the TopAppBar. The LazyColumn was re-applying
it on the modifier, doubling the top gap. Keep the modifier clean
(.fillMaxSize() only) — the Box handles the offset.
* refactor(#445): inline loadModelOptions into loadAll
loadAll() was just delegating to loadModelOptions() with no extra
logic. Consolidate into a single method to remove the redundancy.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* docs: document HermesScaffold padding foot-gun root cause in AGENTS.md
* feat(#446): plugins tab full parity — install from URL, provider selection, remove confirm, badges
- Add Install from URL section with force reinstall + enable after install toggles
- Add Plugin Provider selection section (memory provider + context engine dropdowns)
- Add rescan button in top bar actions
- Add confirmation dialog before removing plugins
- Show source badges, runtime status, auth badges on plugin cards
- Show dashboard slots from plugin manifest
- Add visibility toggle (show/hide in sidebar) for manifest plugins
- Show orphan dashboard plugins section
- Add PluginManifestData, PluginsHubProviders, ProviderOption models
- Add rescanPlugins, savePluginProviders, setPluginVisibility API endpoints
* fix(#446): fix CI compilation errors — DashboardPluginMeta mapping and @Composable context
* fix(#446): pass missing PluginInfo ctor params for orphan plugin mapping
* fix(#446): remove duplicate refresh button in plugins top bar
Scaffold's onRefresh param added a second refresh icon alongside the
rescan action button. Removed onRefresh/isRefreshing from HermesScaffold
and moved pull-to-refresh into a manual PullToRefreshBox wrapping the
content, keeping the rescan icon as the sole top-bar action.
* fix(#446): remove rescan icon from plugins top bar
Only one action icon left in top bar; pull-to-refresh (loadPlugins)
remains the sole refresh mechanism. rescanPlugins() viewmodel logic
is unchanged but now unreachable from the UI.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#445): full Models tab — settings panel, aux tasks, MOA config, model assignment
Main features added:
- Model Settings card at top of screen with main model, aux tasks, MOA summary
- Main model assignment via /api/model/set with expensive model confirm flow
- Auxiliary tasks dialog with per-task model override and reset-all
- MOA config dialog with preset management and reference/aggregator model picker
- Model picker dialog reused for main model, aux tasks, and MOA slot selection
- Kept existing provider browse + pin/unpin/search
New data models: AuxiliaryModelsResponse, MoaConfigResponse,
ModelAssignmentRequest/Response
New API endpoints: GET /api/model/auxiliary, GET/PUT /api/model/moa,
POST /api/model/set
* test(#445): add aux + MOA mock endpoints to E2eIntegrationTest
testModelOptionsSelection_success was failing because the ViewModel's init
block now calls getAuxiliaryModels() and getMoaConfig() which weren't mocked.
Added proper mock returns + imports for the new types.
* fix(#445): correct mock endpoint name in E2eIntegrationTest
- getMoaConfig -> getMoaModels (matches API service method name)
- Add explicit type parameter Response.success<MoaConfigResponse>
to fix Kotlin type inference
* fix(#445): add missing main param to AuxiliaryModelsResponse mock
AuxiliaryModelsResponse requires a MainModelAssignment for its
'main' field. Added the parameter with a dummy value and the
corresponding import.
* fix(#445): update toast assertion in test to match new message format
ViewModel now produces 'Successfully set profile model to ronia'
instead of the old 'Successfully set model to ronia'.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#433): achievements 1:1 parity with dashboard
- Expanded model: AchievementsResponse now carries header stats
(unlockedCount, discoveredCount, secretCount, totalCount, isStale,
scanMeta/ScanStatus) and per-achievement fields (unlockedAt, secret, criteria)
- Added API endpoints: scan-status, rescan, reset-state, recent-unlocks
- Created AchievementIcon emoji mapper for all Lucide icon names
- Rewrote ViewModel with full state: scan tracking, recent unlocks,
category/state filtering, rescan/reset actions with loading states
- Rewrote Screen: header stats row, scan status chip, stale indicator,
category + state filter chips, recent unlocks section, expandable
criteria, icon emoji, unlocked glow border, overflow menu with
rescan/reset, reset confirmation dialog
- Added 30 string resources for all new UI elements
* fix: resolve CI compilation errors in achievements module
- Replace progress_pct references with correct Kotlin field progressPct (Achievement model uses @SerializedName for snake_case, Kotlin field is camelCase)
- Add missing onStart/onError parameters to safeLaunchLoad calls in loadScanStatus() and loadRecentUnlocks()
* fix: remove top padding on achievements tab content
* fix: remove redundant paddingValues on achievement LazyColumn
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Implements a full-screen cron job editor dialog following the skills editor pattern:
- Create/edit/delete cron jobs with all backend fields
- CronJobRepeat data class matching backend object shape
- Fixed Gson crash on cron tab load
* feat(#369): rewrite channels tab with full backend integration
- Add state, error, gateway_running, description to model
- Add test endpoint to API service
- Rewrite ViewModel with toggle, restart, test actions
- Rewrite screen with state badges, restart banner, test button
- Fix string resources and E2E test
* fix(#417): add missing import and fix ButtonSize API
- Add MessagingPlatformTestResult import to HermesApiService.kt
- Replace ButtonDefaults.ButtonSize.SMALL with ButtonDefaults.SmallButtonSize (correct M3 API)
* fix(#417): mark platformStateStyle as @Composable, remove Button size param
- @Composable annotation needed for MaterialTheme.colorScheme access
- Button/OutlinedButton size parameter not available in project's M3 version
- ktlint removed unused ButtonDefaults import
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
- Replace installPlugin(name) with activatePlugin(plugin) that calls enable endpoint
instead of the install endpoint for plugins already on disk (bundled/inactive).
- Gate Uninstall button behind plugin.canRemove — bundled plugins don't show it.
- Update PluginsScreen to call activatePlugin(plugin) instead of installPlugin(name).
- Parse FastAPI 'detail' field from 400 response bodies in mapHttpError/safeApiCall
so users see the actual error message instead of a generic placeholder.
- Import ButtonDefaults to fix max-line-length lint warning.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(#219): add pagination to sessions history tab
The /api/sessions endpoint was called without pagination params, so the server returned only its default limit of 20 sessions.
- SessionListResponse: added total/limit/offset fields from server response
- HermesApiService: added @Query params (limit, offset, order) to getSessions()
- SessionsViewModel: added loadMore() that appends pages, hasMore derived from total
- SessionsScreen: added 'Load more sessions' button + loading spinner at bottom of list
* chore: remove plan file from git
* fix: compilation errors in sessions pagination
* chore: gitignore plan files
* fix: remove duplicate brace
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#349): smart auth login flow — probe dashboard, show correct fields
Landing screen with two buttons (Auth Login / Pairing Login) replaces
ConnectScreen as the start screen. Auth login probes the dashboard to
detect what auth it needs (token-only, basic-auth, or both) and shows
only the relevant input fields.
Changes:
- NavigationKeys.kt: add LandingScreen, AuthLoginScreen NavKeys
- LandingScreen.kt: new screen with hero icon + two login buttons
- AuthLoginScreen.kt: IP/Port input, probe logic, dynamic field rendering
- AuthLoginViewModel.kt: probe API, dashboard auth detection, connect flow
- Navigation.kt: wire new screens, start on LandingScreen, hide bottom bar
- strings.xml: add string resources for both screens
* fix(#351): align AuthLoginScreen field names with ViewModel API, fix TAG refs
- state.isProbing → state.probing
- DashboardAuthMode.BOTH → DashboardAuthMode.ALL
- viewModel.probe() → viewModel.probeDashboard()
- state.isConnecting → state.isLoading
- Removed unused TAG constant, replaced with inline tag string
- Made connectTokenOnly suspend to fix safeApiCall compilation
This fixes the CI compilation errors.
* fix(#351): revert probeDashboard() call to probe() to match VM
* fix(#351): rename isProbing to probing in ViewModel
* fix(#351): follow redirects in basic auth login — / redirects to /login
* fix(#351): show all fields when basic auth detected — still needs session token for WS
* refactor(#349): remove old ConnectScreen, clean up NavKey + navigation
* fix(#351): validate token against /api/sessions (auth-protected) instead of /api/status (public)
/api/status is a public endpoint that returns 200 even with wrong tokens,
causing invalid tokens to be saved and the WebSocket to spin forever on
the chat screen. /api/sessions requires auth and returns 401 on bad tokens.
* fix(#351): rewrite basic auth login — POST to /auth/password-login, then fetch SPA with cookie to extract token
* fix(#351): use ticket-based WS auth when basic auth is active
The updated dashboard on 0.0.0.0 always has auth_required=True, which
rejects legacy ?token= in favor of ?ticket= (single-use, 30s TTL).
Added AuthManager.setWsAuthParam() to support both ?token= (loopback)
and ?ticket= (gated mode) in wsUrl(). connectBasicAuth now mints a
ticket via POST /api/auth/ws-ticket after login instead of extracting
the token from the SPA.
* fix(#351): use requirePrefs() instead of direct prefs reference in wsUrl()
* fix(#351): handle relaxed-mock empty string in wsUrl() authParam fallback
* feat(#349): auto-extract session token from SPA HTML during probe
The probe now extracts the __HERMES_SESSION_TOKEN__ value from the
dashboard SPA and auto-populates the token field in TOKEN_ONLY mode,
eliminating the need for manual token pasting.
- Introduced ProbeResult data class to carry authMode + extractedToken
- Regex extraction of __HERMES_SESSION_TOKEN__ from dashboard HTML
- Token field auto-filled on probe completion for TOKEN_ONLY mode
* fix(#349): return BASIC_AUTH from probe when basic auth detected
Bug: probe returned ALL instead of BASIC_AUTH for basic-auth-only
dashboards, causing token+credential fields to show and the connect
flow to fall through to connectTokenOnly() on basic auth failure,
producing a confusing 'Unauthorized (HTTP 401)' NetworkResult error.
Fix: probe now returns DashboardAuthMode.BASIC_AUTH when a redirect
to /login is detected. BASIC_AUTH in connect() calls
connectBasicAuth() directly with no fallback. ALL mode also no longer
falls through to connectTokenOnly().
* fix(#349): use session cookie for REST API in gated mode
The dashboard's gated mode authenticates REST API requests via
session cookies (hermes_session_at), not Bearer tokens. The app
was storing the WS ticket as the token and sending it as
Authorization: Bearer <ticket> for all REST calls, which the
server rejected with 401.
- Extract hermes_session_at cookie from Set-Cookie header after
POST /auth/password-login and store it in AuthManager
- ApiClient authInterceptor now sends Cookie header when session
cookie is present, falls back to Bearer token for loopback mode
- ConnectResult carries both WS credential (ticket) and session
cookie separately
- WS ticket used only for WebSocket (?ticket=), session cookie
used for REST API
* feat(#349): add logout button to Settings > About
Red error-style button that clears all auth credentials (token,
session cookie, WS auth param) and navigates back to LandingScreen.
Wired through SettingsScreen onLogout callback to Navigation.kt.
* fix(#349): improve logout navigation flow
* fix(#349): keep backstack stable across logout
Use remember without keys for NavBackStack so the same backstack
object persists across startScreen changes. Use LaunchedEffect keyed
on startScreen to reset stack content atomically. This avoids
NavDisplay losing snapshot observation when a new NavBackStack
object is created.
* fix(#349): mint fresh WS ticket on every connect
The WS ticket is a single-use 30-second credential. On app restart
(or reconnect), the stored ticket is expired. Now refreshWsTicketIfNeeded()
mints a new ticket using the session cookie before every WS connection
attempt. Loopback mode (no session cookie) is unaffected.
* fix(#349): refresh ticket in openSocket for reconnect paths
scheduleReconnect() bypasses connect() and calls openSocket()
directly. Moving the ticket refresh into openSocket() ensures
every WS connection attempt — first launch and reconnect —
uses a fresh ticket.
* fix(#349): stub getSessionCookie() in ApiClientTest
mockkObject(AuthManager) is strict, so unstubbed getSessionCookie()
calls throw MockKException which manifests as IOException.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* Fix Medium severity performance findings (PERF-17 to PERF-21)
This commit addresses the following performance findings:
- PERF-17: Implemented a message queue in `HermesWsClient` using `ConcurrentLinkedQueue`. If the WebSocket is disconnected, messages are queued and flushed upon a successful connection (`onOpen`).
- PERF-18: Added exponential backoff to `safeApiCall` for 5xx and IO exceptions, reducing the load on a struggling server.
- PERF-19: Optimized Kanban task filtering by performing `groupBy` upfront inside a `remember` block and passing item keys to `LazyColumn`, avoiding O(C×T) calculation per recomposition.
- PERF-20: Updated `getLogs` API in `HermesApiService` to accept `lines` and `limit` query params to truncate logs early, avoiding potential JSON parsing OOMs with large log files.
- PERF-21: Optimized drawer entry generation in `Navigation.kt` by grouping navigation items inside a `remember` block.
Fixes#282
* Fix Medium severity performance findings (PERF-17 to PERF-21)
This commit addresses the following performance findings:
- PERF-17: Implemented a message queue in `HermesWsClient` using `ConcurrentLinkedQueue`. If the WebSocket is disconnected, messages are queued and flushed upon a successful connection (`onOpen`).
- PERF-18: Added exponential backoff to `safeApiCall` for 5xx and IO exceptions, reducing the load on a struggling server.
- PERF-19: Optimized Kanban task filtering by performing `groupBy` upfront inside a `remember` block and passing item keys to `LazyColumn`, avoiding O(C×T) calculation per recomposition.
- PERF-20: Updated `getLogs` API in `HermesApiService` to accept `lines` and `limit` query params to truncate logs early, avoiding potential JSON parsing OOMs with large log files.
- PERF-21: Optimized drawer entry generation in `Navigation.kt` by grouping navigation items inside a `remember` block.
Fixes#282
* Fix Medium severity performance findings (PERF-17 to PERF-21)
This commit addresses the following performance findings:
- PERF-17: Implemented a message queue in `HermesWsClient` using `ConcurrentLinkedQueue`. If the WebSocket is disconnected, messages are queued and flushed upon a successful connection (`onOpen`).
- PERF-18: Added exponential backoff to `safeApiCall` for 5xx and IO exceptions, reducing the load on a struggling server.
- PERF-19: Optimized Kanban task filtering by performing `groupBy` upfront inside a `remember` block and passing item keys to `LazyColumn`, avoiding O(C×T) calculation per recomposition.
- PERF-20: Updated `getLogs` API in `HermesApiService` to accept `lines` and `limit` query params to truncate logs early, avoiding potential JSON parsing OOMs with large log files.
- PERF-21: Optimized drawer entry generation in `Navigation.kt` by grouping navigation items inside a `remember` block.
Fixes#282
* fix: resolve ktlint formatting issues in KanbanScreen.kt and Navigation.kt
* fix: fix ktlint import ordering in HermesWsClient.kt
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix: implement in-memory token caching to avoid redundant prefs decryptions
Resolves#277
Previously, the `AuthManager.getToken()` method decrypted the entire
`EncryptedSharedPreferences` file on every call. Because this method is
used in the OkHttp interceptor on every HTTP/WS request, and frequently
during navigation, it introduced 10-30ms of redundant work per call.
This compounded to noticeable UI lag (e.g., during chat session switches).
This commit implements thread-safe in-memory caching for the auth token
inside `AuthManager`.
- Added a `@Volatile` `cachedToken` and `tokenInitialized` flag.
- Modified `getToken()` to return the cached value after initial evaluation,
using double-checked locking (`synchronized(this)`) to avoid race conditions.
- Updated `setToken()` and `setSelectedProfileId()` to correctly invalidate
or update the cache.
- Added comprehensive unit tests in `AuthManagerTest.kt` to verify caching
behavior and proper invalidation upon profile switches.
* fix: implement in-memory token caching to avoid redundant prefs decryptions
Resolves#277
Previously, the `AuthManager.getToken()` method decrypted the entire
`EncryptedSharedPreferences` file on every call. Because this method is
used in the OkHttp interceptor on every HTTP/WS request, and frequently
during navigation, it introduced 10-30ms of redundant work per call.
This compounded to noticeable UI lag (e.g., during chat session switches).
This commit implements thread-safe in-memory caching for the auth token
inside `AuthManager`.
- Added a `@Volatile` `cachedToken` and `tokenInitialized` flag.
- Modified `getToken()` to return the cached value after initial evaluation,
using double-checked locking (`synchronized(this)`) to avoid race conditions.
- Updated `setToken()` and `setSelectedProfileId()` to correctly invalidate
or update the cache.
- Added comprehensive unit tests in `AuthManagerTest.kt` to verify caching
behavior and proper invalidation upon profile switches.
* fix: implement in-memory token caching to avoid redundant prefs decryptions
Resolves#277
Previously, the `AuthManager.getToken()` method decrypted the entire
`EncryptedSharedPreferences` file on every call. Because this method is
used in the OkHttp interceptor on every HTTP/WS request, and frequently
during navigation, it introduced 10-30ms of redundant work per call.
This compounded to noticeable UI lag (e.g., during chat session switches).
This commit implements thread-safe in-memory caching for the auth token
inside `AuthManager`.
- Added a `@Volatile` `cachedToken` and `tokenInitialized` flag.
- Modified `getToken()` to return the cached value after initial evaluation,
using double-checked locking (`synchronized(this)`) to avoid race conditions.
- Updated `setToken()` and `setSelectedProfileId()` to correctly invalidate
or update the cache.
- Added comprehensive unit tests in `AuthManagerTest.kt` to verify caching
behavior and proper invalidation upon profile switches.
- Fixed GitGuardian warning by removing the "my_secret_token" from the test.
* fix: implement in-memory token caching to avoid redundant prefs decryptions
Resolves#277
Previously, the `AuthManager.getToken()` method decrypted the entire
`EncryptedSharedPreferences` file on every call. Because this method is
used in the OkHttp interceptor on every HTTP/WS request, and frequently
during navigation, it introduced 10-30ms of redundant work per call.
This compounded to noticeable UI lag (e.g., during chat session switches).
This commit implements thread-safe in-memory caching for the auth token
inside `AuthManager`.
- Added a `@Volatile` `cachedToken` and `tokenInitialized` flag.
- Modified `getToken()` to return the cached value after initial evaluation,
using double-checked locking (`synchronized(this)`) to avoid race conditions.
- Updated `setToken()` and `setSelectedProfileId()` to correctly invalidate
or update the cache.
- Added comprehensive unit tests in `AuthManagerTest.kt` to verify caching
behavior and proper invalidation upon profile switches.
- Fixed GitGuardian warning by removing the "my_secret_token" from the test.
- Fixed merge conflicts and ensured code still tests well.
* fix: implement in-memory token caching to avoid redundant prefs decryptions
Resolves#277
Previously, the `AuthManager.getToken()` method decrypted the entire
`EncryptedSharedPreferences` file on every call. Because this method is
used in the OkHttp interceptor on every HTTP/WS request, and frequently
during navigation, it introduced 10-30ms of redundant work per call.
This compounded to noticeable UI lag (e.g., during chat session switches).
This commit implements thread-safe in-memory caching for the auth token
inside `AuthManager`.
- Added a `@Volatile` `cachedToken` and `tokenInitialized` flag.
- Modified `getToken()` to return the cached value after initial evaluation,
using double-checked locking (`synchronized(this)`) to avoid race conditions.
- Updated `setToken()` and `setSelectedProfileId()` to correctly invalidate
or update the cache.
- Added comprehensive unit tests in `AuthManagerTest.kt` to verify caching
behavior and proper invalidation upon profile switches.
- Fixed GitGuardian warning by removing the "my_secret_token" from the test.
* fix: implement in-memory token caching to avoid redundant prefs decryptions
Resolves#277
Previously, the `AuthManager.getToken()` method decrypted the entire
`EncryptedSharedPreferences` file on every call. Because this method is
used in the OkHttp interceptor on every HTTP/WS request, and frequently
during navigation, it introduced 10-30ms of redundant work per call.
This compounded to noticeable UI lag (e.g., during chat session switches).
This commit implements thread-safe in-memory caching for the auth token
inside `AuthManager`.
- Added a `@Volatile` `cachedToken` and `tokenInitialized` flag.
- Modified `getToken()` to return the cached value after initial evaluation,
using double-checked locking (`synchronized(this)`) to avoid race conditions.
- Updated `setToken()` and `setSelectedProfileId()` to correctly invalidate
or update the cache.
- Added comprehensive unit tests in `AuthManagerTest.kt` to verify caching
behavior and proper invalidation upon profile switches.
- Fixed GitGuardian warning by removing the "my_secret_token" from the test.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* Fix WebSocket events being silently dropped on buffer overflow
Increases the MutableSharedFlow `extraBufferCapacity` from 64 to 256
and changes the `onBufferOverflow` policy to `BufferOverflow.DROP_OLDEST`
in `HermesWsClient.kt`. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.
By setting `DROP_OLDEST`, `tryEmit()` remains functional without
suspending, allowing real-time events to be retained and prioritized.
Fixes#275
* Fix ktlint formatting issues in HermesWsClient.kt
The formatting changes adhere to ktlint rules required by the CI pipeline:
- Removed a trailing comma in MutableSharedFlow initialization to avoid multiline expression wrapping errors.
- Sorted imports to be in lexicographical order.
These formatting changes fix the failing CI check.
Fixes#275
* Fix WebSocket events being silently dropped on buffer overflow
Increases the MutableSharedFlow `extraBufferCapacity` from 64 to 256
and changes the `onBufferOverflow` policy to `BufferOverflow.DROP_OLDEST`
in `HermesWsClient.kt`. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.
By setting `DROP_OLDEST`, `tryEmit()` remains functional without
suspending, allowing real-time events to be retained and prioritized.
Fixes#275
* Fix WebSocket events being silently dropped on buffer overflow
Increases the MutableSharedFlow extraBufferCapacity from 64 to 256
and changes the onBufferOverflow policy to BufferOverflow.DROP_OLDEST
in HermesWsClient.kt. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.
By setting DROP_OLDEST, tryEmit() remains functional without
suspending, allowing real-time events to be retained and prioritized.
Fixes#275
* Fix WebSocket events being silently dropped on buffer overflow
Increases the MutableSharedFlow extraBufferCapacity from 64 to 256
and changes the onBufferOverflow policy to BufferOverflow.DROP_OLDEST
in HermesWsClient.kt. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.
By setting DROP_OLDEST, tryEmit() remains functional without
suspending, allowing real-time events to be retained and prioritized.
Fixes#275
* Fix WebSocket events being silently dropped on buffer overflow
Increases the MutableSharedFlow extraBufferCapacity from 64 to 256
and changes the onBufferOverflow policy to BufferOverflow.DROP_OLDEST
in HermesWsClient.kt. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.
By setting DROP_OLDEST, tryEmit() remains functional without
suspending, allowing real-time events to be retained and prioritized.
Fixes#275
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* Fix: Prevent AuthManager state pollution on connection verification
Modified `ConnectViewModel.connect()` to construct a temporary OkHttpClient and Retrofit builder, isolated from the global `ApiClient` state. The connection verification is executed using this temporary service. Only upon a successful HTTP response does the system persist the configured `host`, `port`, and `token` to the global `AuthManager` and triggers an `ApiClient.rebuild()`. This resolves the issue where a failed connection attempt would overwrite active and verified profiles. Extracted `createTempService` factory to `ApiClient` for robustness and clean tests.
Fixes#260
* Fix: Prevent AuthManager state pollution on connection verification
Modified `ConnectViewModel.connect()` to construct a temporary OkHttpClient and Retrofit builder, isolated from the global `ApiClient` state. The connection verification is executed using this temporary service. Only upon a successful HTTP response does the system persist the configured `host`, `port`, and `token` to the global `AuthManager` and triggers an `ApiClient.rebuild()`. This resolves the issue where a failed connection attempt would overwrite active and verified profiles. Extracted `createTempService` factory to `ApiClient` for robustness and clean tests.
Fixes#260
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(#283): apply minor performance optimizations for Chat
- Optimized `ChatScreen.kt` background gradient to use `remember` block correctly keyed by individual colors to avoid reallocation on recomposition.
- Removed redundant `luminance()` calculation caching inside `ChatBubble.kt` to preserve dynamic theme changes correctly while keeping code concise.
- Refactored `pendingRequests` timeout loop in `ChatViewModel.kt` to use `iterator.remove()` instead of generating a new filtered list.
- Converted `SideEffect` for `initialSessionId` assignment in `ChatScreen` to `remember` block to guarantee synchronous setup before WebSocket processes it.
- Initialized an explicit OkHttp `ConnectionPool` in `ApiClient` to survive client rebuilds.
These changes resolve Low-severity performance findings (PERF-22 to PERF-26) mentioned in issue #283 while ensuring maintainability and preserving dynamic theming.
* fix(#283): apply minor performance optimizations for Chat
- Optimized `ChatScreen.kt` background gradient to use `remember` block correctly keyed by individual colors to avoid reallocation on recomposition.
- Removed redundant `luminance()` calculation caching inside `ChatBubble.kt` to preserve dynamic theme changes correctly while keeping code concise.
- Refactored `pendingRequests` timeout loop in `ChatViewModel.kt` to use `iterator.remove()` instead of generating a new filtered list.
- Converted `SideEffect` for `initialSessionId` assignment in `ChatScreen` to `remember` block to guarantee synchronous setup before WebSocket processes it.
- Initialized an explicit OkHttp `ConnectionPool` in `ApiClient` to survive client rebuilds.
These changes resolve Low-severity performance findings (PERF-22 to PERF-26) mentioned in issue #283 while ensuring maintainability and preserving dynamic theming.
- Added `CertificatePinner` configuration to `OkHttpClient` builders in `ApiClient.kt` and `HermesWsClient.kt`.
- Provided an empty `CertificatePinner.Builder().build()` along with a security note (SEC-11) explaining that hardcoded pinning is bypassed by default because the application dynamically connects to user-defined local HTTP endpoints.
- This fulfills the security requirement to have pinning infrastructure available for future use with known HTTPS hosts while maintaining existing LAN functionality.
Fixes#264
GET /api/messaging/platforms returns a JSON object with a platforms
array inside, but the app expected a bare array. Wraps the response
in a new MessagingPlatformResponse model.
- Adds MessagingPlatformResponse.kt wrapper model
- Updates HermesApiService.kt return type + adds profile query param
- Updates ChannelsViewModel.kt to unwrap .platforms
- Updates E2eIntegrationTest.kt mock to match new response shape
Wave 1 — CRITICAL (token + PII leak):
B1 (kanban t_afc1d26f): Auth token leaked to logcat via
HttpLoggingInterceptor.Level.BODY combined with the authInterceptor
that adds `Authorization: Bearer *** on every REST call. Enabled
buildConfig=true in app/build.gradle.kts and gate Level.BODY on
BuildConfig.DEBUG (Level.NONE in release). ApiClient.kt:44-56.
B2 (kanban t_8884db16): Chat prompts + AI replies leaked to logcat
via unguarded Log.d in HermesWsClient. Gated the 3 PII-critical
sites (line 104 outgoing JSON which contains user `text`, line ~123
connecting URL whose query string carries the auth token, line ~181
incoming WS payload with AI reply tokens and tool output) with
BuildConfig.DEBUG. Log.i/.w/.e left unguarded as intentional ops signals.
Wave 2 — HIGH (races + thread-pattern):
B3 (kanban t_33da8a97): switchSession fired SESSION_RESUME (WS) and
loadSessionMessages (REST) in parallel. The REST success path did an
unconditional `_uiState.update { it.copy(messages = chatMessages) }`
that stomped any in-flight streaming tokens appended via
WsEvent.MessageToken. ChatViewModel.loadSessionMessages now preserves
messages with isStreaming=true by re-appending them to the freshly
loaded history. Bonus side-fix discovered in same function: isLoading
was never set to false on success or error branches — now explicitly
cleared on all exit paths.
B4 (kanban t_2b834d90): HermesWsClient.scheduleReconnect used a raw
Thread{} with Thread.sleep + a non-atomic check-then-openSocket()
(TOCTOU). Two reconnect threads spawned by a rapid onFailure/onClosed
cycle could both pass the gate and both call openSocket(), the second
overwriting `webSocket` without closing the first — orphaned socket
listener leak. Flaky networks multiplied daemon thread count. Replaced
with a single cancellable coroutine Job keyed to wsScope
(Dispatchers.IO + SupervisorJob). disconnect() now cancels any pending
reconnect so a delayed openSocket() can't fire after explicit close.
Wave 3 — MEDIUM (UX gaps + persistence):
B5 (kanban t_2322818d): LogsScreen rendered a blank black Box when
state.logs was empty && !isLoading && errorMessage == null. Added an
explicit empty-state branch rendering "No logs available" in
onSurfaceVariant, centered. Mirrors sibling pattern in
PairingScreen.kt:200-219 and WebhooksScreen.kt:171-190.
B6 (kanban t_86e9be9b): Theme preference was in-memory only — lost on
every cold start. Verification confirmed three gaps: (1)
SettingsViewModel.loadSettings() didn't restore themePreference from
storage; (2) SettingsViewModel.save() didn't persist it; (3)
MainActivity called `HermesControlTheme {}` with no themePreference
arg so even if storage had worked, the app wouldn't apply it at
startup. Added getThemePreference/setThemePreference in AuthManager
(EncryptedSharedPreferences round-trip, validate stored enum name with
runCatching to handle forward-incompatible strings), wired
SettingsViewModel init + save, and passed the stored preference to
HermesControlTheme(themePreference = AuthManager.getThemePreference())
at startup.
Verification:
- ktlintCheck: BUILD SUCCESSFUL in 7s (ktlint app/src/**/*.kt — clean)
- ./gradlew test: NOT RUN on this host (no Android SDK at
$ANDROID_HOME); deferred to the CI workflow at
.github/workflows/android.yml. Same limitation applies to
assembleRelease. Symbol-resolution reasoning: app/build.gradle.kts:10
declares namespace=com.m57.hermescontrol so AGP auto-generates the
BuildConfig class at package com.m57.hermescontrol — matches the new
imports in ApiClient.kt and HermesWsClient.kt.
Kanban tracking (native Hermes kanban, board slug "hermes-mobile"):
B1 t_afc1d26f done - Auth token leak via Level.BODY
B2 t_8884db16 done - Chat PII via unguarded Log.d
B3 t_33da8a97 done - switchSession race stomps streaming tokens
B4 t_2b834d90 done - WS reconnect TOCTOU + unbounded Thread{}
B5 t_2322818d done - LogsScreen missing empty-state
B6 t_86e9be9b done - Theme preference not persisted
B7 t_74f3b383 ready - (deferred) NavigationController.goBack underflow
B8 t_39a149f0 ready - (deferred) KanbanBoardsResponse.current nullable
B9 t_4b1465cf ready - (deferred) E2eIntegrationTest base64 no-op via Mockk
B10 t_81ef4203 ready - (deferred) ChannelsScreen form state on scroll
B11 t_3325498b ready - (deferred) TEST_READY/TEST_INFRA stale (28 vs 123)
B12 t_674007f5 ready - (deferred) Hardcoded http:// (LAN assumption)
B13 t_179009d5 ready - (deferred) SystemViewModel silent doctor catch
Total: 8 files changed, +144 insertions, -26 deletions.
Co-Authored-By: Ronia <ronia@hermes>