* refactor(theme): unify all presets behind one palette template
* docs(theme): update THEMES.md and AGENTS.md for the palette template
* fix(theme): restore per-preset error slot contrast, merge StatusColors into HermesStatusColors
- onErrorContainer becomes an explicit per-preset field (was derived from
onError — same-color pairs in Gruvbox/Catppuccin light made error text
invisible; 6 screens render this slot)
- Default dark + AMOLED onError -> White (were <3:1 on their error color)
- drop StatusColors + statusColorsOf mapper; PaletteColors.status is typed
HermesStatusColors directly (identical field lists, pure copy ceremony)
- ThemePalette getters now by lazy; schemeFor/statusFor accessors;
requireNotNull on the default-theme fallback (DefaultTheme is FULL)
- resolvers internal for testability
* test(theme): contrast, mode-validation, and fallback contract tests
- every shipped mode's onError/error + onErrorContainer/errorContainer
pair meets >= 3:1 WCAG contrast (kills the error-slot regression class)
- ThemeMode init validation rejects illegal palette combinations
- AMOLED darkOnly contract + light-mode fallback to Default light
* refactor(theme): Catppuccin — named swatches from the official palette spec
Hex values verified identical (zero color drift): repeated colors (Crust,
Surface0/1, Base, Text...) now live once as named swatches matching
catppuccin.com; bespoke light primary container tints stay local.
Mocha/Latte pair unchanged.
* refactor(theme): Gruvbox — authentic bright/faded accent scheme with named swatches
Redesign per gruvbox spec: bright accents on dark bg0-bg4, faded accents
on light bg0-bg4 (correct contrast play for light mode). Named swatches,
single Gray shared across modes. Dark error pair fixed for >= 3:1
(onErrorContainer -> DarkFg1 cream, was bright red on Bg3 = 1.87:1).
* feat(theme): replace default preset with Slate — modern slate blues + teal accents
The brand default (voltage purple/amber) becomes Slate: bright teal/blue
accents on dark bg0-bg4, faded variants on light bg0-bg4. Named swatches,
single neutral gray shared across modes. Slate is also the fallback for
darkOnly/lightOnly presets (AMOLED light mode).
* feat(theme): add Nord preset — Polar Night/Snow Storm surfaces, Frost/Aurora accents
Nord's single accent set is reused across both modes (pastel accents stay
light, so on* colors use Polar Night dark text in both modes — documented
in-file). Surface ladder shares outer tiers (Nord ships 4 dark + 3 light
tones for 5 tiers). Dark error pair fixed for >= 3:1 (onErrorContainer ->
Nord6, was Nord11 on Nord2 = 2.0:1). Wired into ThemePreset, the settings
picker, and docs; default label now reads Default (Slate).
* refactor(theme): Monochrome + AMOLED — pure grayscale ramp, true-black AMOLED variant
Monochrome: every swatch R=G=B, roles by lightness only (status colors
included — documented trade-off: pair with icons/labels). AMOLED: dark-only
true-black variant (pure #000000, OLED pixels off; #121212 baseline of
Monochrome doesn't do that), containers collapse where nothing darker
exists. Both ship maximum-contrast grayscale pairs (12.8:1+ on error
slots).
* chore(theme): drop Neon Noir, finalize docs + picker label
- remove NeonNoirScheme.kt + ThemePreset.NEON_NOIR everywhere (dispatcher,
picker, strings EN/KO, tests, AGENTS.md, THEMES.md) — 6 presets remain:
Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord
- picker label for the default preset is now plain 'Default'
- THEMES.md: preset conventions section (named swatches, bright/faded
split, grayscale-status caveat, contrast test gate) + verify step runs
testDebugUnitTest
* fix(theme): make drawer icon colors visibly follow the active preset
Selected icon now tints with the theme's primary accent (teal for Slate,
orange for Gruvbox, mauve for Catppuccin, frost-blue for Nord) instead of
onPrimaryContainer — the new presets' containers are all dark gray with
near-white text, so the selected row looked identical across themes.
Unselected icon/text colors pinned explicitly to onSurfaceVariant.
* chore(ui): remove lightning emoji from the app
- drawer title + About name: '⚡ Hermes' -> 'Hermes' (EN + KO)
- skill markers (chat self-improvement badge, memory graph): ⚡ -> 🛠
- in-progress indicators (subagent inspection): ⚡ -> 🔄
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Fix active saved profile visibility in Catppuccin light mode and Monochrome light/dark modes by adjusting primaryContainer contrast and adding a primary border stroke.
* 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>
* [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>
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>
* refactor(settings): drill-down navigation replacing tabbed layout
Replaces the 4-tab settings layout with a native Android-style drill-down:
a single category list (SettingsScreen) where each row pushes a dedicated
sub-page onto the Navigation3 back stack (SettingsConnection / Appearance /
Chat / NavBar / Behavior / About). Each sub-page is its own NavKey destination
registered in Navigation.kt and reuses the existing section composables.
- SettingsScreen becomes the category list with icon + label + summary
(Appearance row shows live theme + language, e.g. "Dark · 한국어").
- New SettingsSubPages.kt hosts the 6 full-screen sub-pages.
- AboutSection promoted out of SettingsScreen into its own sub-page.
- SettingsViewModel remains the single source of truth; no persistence changes.
- Adds settings_summary_* strings for list subtitles.
Consumes the in-app language switcher from the prior commit (AppearancePage
passes appLanguage through to AppearanceSection).
* [bug] fix settings sub-page back button opening drawer on first tap
The ModalNavigationDrawer wraps the whole app and stays gesture-enabled on
the Settings root (a DRAWER_GESTURE_SCREEN). Drilling into a sub-page
(SettingsConnection/Appearance/Chat/NavBar/Behavior/About) disables gestures,
but the existing LaunchedEffect only snapped the drawer closed when it was
already isOpen. The drawer's scrim surface lingered during the transition and
intercepted the first back-button tap, sliding the sidebar open instead of
navigating back.
Now snapTo(Closed) unconditionally whenever gestures are disabled — a safe
no-op when already closed — so the sub-page back button works on the first tap.
* [bug] fix settings sub-page back button opening drawer (gesture-surface race)
The first "back" tap on a settings sub-page opened the sidebar instead of
navigating back. Root cause: Settings root is a gesture-enabled drawer screen,
and the sub-pages are the first destinations ever drilled down from it. When
navigating in, ModalNavigationDrawer's gesture interceptor surface lingered
for a frame (gesture -> non-gesture transition), so the top-left tap was
consumed by the drawer instead of the Back IconButton. The previous snapTo
fix only reset drawer *state*, not the gesture layer, so it didn't help.
Fix: NavigationController now holds a closeDrawer hook (set by MainNavigation)
and invokes it synchronously inside navigateTo() when pushing a non-primary
sub-page. The drawer is dismissed in the same tap handler that triggers the
navigation, before any recomposition, so the sub-page is never tappable while
the gesture surface is still mounted. The LaunchedEffect(gesturesEnabled)
snapTo remains as a backstop for non-navigateTo entries.
* fix(#603): delay drawer gesture activation to prevent residual swipe
* fix(#603): fix settings back navigation and drawer auto-open
---------
Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
Add a Language selector to Settings → Appearance that overrides the app
display language independent of the device locale. Persists a BCP-47 code
in ServerStoreState.appLanguage; MainActivity.attachBaseContext applies it
via a ContextWrapper (works on minSdk 26, no AppCompat dependency needed
since the Activity is a plain ComponentActivity). Selecting a language
recreates the activity so resources re-resolve immediately.
Includes System default / English / 한국어 options, plus a unit test for
the locale-code resolver.
Note: the new string keys (settings_item_language, language_system,
language_english, language_korean) should be backfilled into values-ko
once PR #602 lands.
Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
* refactor(settings): remove current connection editor fields
Remove the active host, port, token fields, and clear token button from the main Connection tab, as profiles are now managed exclusively through the ProfileEditorDialog.
* feat(settings): migrate to full auto-save design and remove save button
Update settings change listeners (auto-reconnect, typing effect delay/toggle) to save state immediately to AuthManager. Remove the manual Save button from the scaffold's top bar and delete the redundant SaveIndicator.
* style(settings): clean up unused imports from settings UI cleanup
* fix(chat): resolve compose resource lint error and initial state test failure
Pre-resolve empty credentials string resource at the composable scope to fix LocalContext resource query lint error. Update testInitialStateAndConnection to use DISCONNECTED state to trigger connection and pass verification.
* refactor(ui): extract SkillsStep to components package
Move the 252-LOC SkillsStep composable out of ProfilesScreen.kt into
ui/profiles/components/SkillsStep.kt. Body is verbatim (behavior-preserving);
only package + imports changed. Parent now imports and calls it directly.
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
Note: sibling screens resolve Modifier.weight() without an explicit import;
matching that convention (no spurious weight import) to avoid the
internal RowColumnParentData.weight shadowing error.
* refactor(ui): extract ProfileBuilderView cluster to components package
Move the Profile Builder wizard (ProfileBuilderView + validateProfileName +
IdentityStep/ModelStep/McpStep/AddMcpDialog/ReviewStep) out of ProfilesScreen.kt
into ui/profiles/components/ProfileBuilderView.kt. Body is verbatim
(behavior-preserving); only package + imports changed.
Notable wiring required:
- validateProfileName kept as internal fun: it is also used by the parent's
clone dialog, so both the screen and the builder share it via import.
- ProfilesUiState/ProfilesViewModel imported from the ui.profiles package.
- SkillsStep call resolves to the already-extracted sibling file (same package).
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
* refactor(ui): extract MoaConfigDialog cluster to components package
Move the Mixture-of-Agents config dialog (MoaConfigDialog + MoaConfigEditor +
MoaSlotPicker + MoaPickerTarget sealed class) out of ModelScreen.kt into
ui/model/components/MoaConfigDialog.kt. Body is verbatim (behavior-preserving);
only package + imports + entry visibility changed.
- Entry MoaConfigDialog is internal (called by ModelScreen root); the editor,
slot picker, and sealed class stay private to the new file.
- The @Composable annotation preceding the declaration is moved with the
cluster (earlier attempt left it orphaned, causing a duplicate-annotation
compile error at the next declaration).
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
* refactor(ui): extract PlatformCard + TelegramOnboardingDialog clusters
Move two self-contained clusters out of ChannelsScreen.kt:
- PlatformCard.kt: StateStyle (private data class) + platformStateStyle +
StateBadge + PlatformCard. Entry PlatformCard is internal (called by
ChannelsScreen root); StateStyle/StateBadge/platformStateStyle stay private.
- TelegramOnboardingDialog.kt: TelegramOnboardingDialog. Entry internal.
Wiring required:
- ConfigureForm kept in ChannelsScreen as internal (shared by PlatformCard
and the screen root) and imported into PlatformCard.kt.
- ChannelsUiState/OnboardingPhase imported from the ui.channels package.
- formatTimestamp left in the screen (used by ConfigureForm).
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
* refactor(ui): extract SkillsScreen sub-composables to components package
Split the 914-line SkillsScreen.kt into the screen root plus 4 cohesive
component files under ui/skills/components/:
- SourceBadge.kt: shared SourceBadge (internal, used by both tabs)
- InstalledSkillsTab.kt: InstalledSkillsView + SkillCard + SkillPreviewDialog
- HubSkillsTab.kt: HubBrowseView + HubSkillCard
- SkillEditorDialog.kt: SkillEditorDialog
Wiring required:
- CATEGORY_ALL promoted private -> internal in SkillsScreen (shared by the
screen root and InstalledSkillsView) and imported where needed.
- SkillFilter / SkillsUiState imported from the ui.skills package.
- HubBrowseView now reads isHubSearching/hubResults/hubSearchError off its
SkillsUiState param (fields, not locals) - no behavior change.
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
* refactor(ui): extract SettingsScreen + SystemScreen sub-composables
SettingsScreen.kt (1238 -> ~340): move self-contained sections into
ui/settings/components/ - ConnectionSection (+TestResultCard/SaveIndicator/
TestConnectionButton), BehaviorSection, AppearanceSection (+ChatSection/
NavBarSection), AboutSection, ProfileEditorDialog (+DeleteProfileConfirmDialog).
Shared leaves SectionCard/InfoRow kept in the screen as internal and imported
by the extracted files.
SystemScreen.kt (1755 -> 1619): move CredentialEntryRow + HookCard into
ui/system/components/ (both internal). formatBytes/formatDuration helpers stay
in the screen (used by the root and remaining content).
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
* fix(#485): use LocalHermesStatusColors for PlatformCard state colors
Map platform connection-status badges to semantic LocalHermesStatusColors
slots (success/warning/error) + add neutral slot for not_configured/disabled.
Removes hardcoded hex (Color(0xFF...)) and MaterialTheme.colorScheme usage
flagged by Sourcery; neutral defaults to StatusGrey so existing theme
providers are untouched.
* Remove Settings connection profile dropdown; make saved-profile cards tappable to switch
The profile dropdown in the Settings > Connection tab was the only way to
switch the active profile from Settings. Removing it left a regression, so
each saved-profile Card now has clickable { viewModel.selectProfile(profile.id) }
reusing the existing isActive highlight. The isNotEmpty guard stays so the
section hides cleanly when there are no profiles.
* Tighten spacing between Test Connection and Logout buttons (16dp -> 8dp)
* Tighten Test Connection / Logout spacing further (8dp -> 4dp)
* Tighten Test Connection / Logout spacing further (4dp -> 2dp)
* Add accessibility role to profile cards (clickable role = Role.Button)
Sourcery flagged the now-clickable profile Card as missing semantics for
assistive tech. Use clickable(role = Role.Button) so TalkBack announces it
as an actionable control. Role import added.
- Remove the Logout button from the About tab content.
- Add the Logout button at the bottom of the Connection tab so users
can sign out directly where they manage their connection.
- Delete the unused, duplicate AboutSection() composable (it was never
called and carried its own Logout button) to keep a single source of
truth for the action.
Logout behavior is unchanged: it still calls viewModel.logout() then
onLogout() (navigates to login). Verified with assembleDebug + ktlint.
* fix(#478): rework default login to use unified ConnectionProfile model
- AuthManager: drop legacy auth_token path; getToken/setToken now delegate
to the selected profile (profile token only, no global fallback)
- setHost/setPort always write the selected profile (no top-level host/port branch)
- Add DEFAULT_PROFILE_ID + ensureDefaultProfile()/ensureDefaultSelected() so the
selected profile is never null; ensureDefaultSelected() only falls back when
nothing is selected (never clobbers an existing choice); ensureDefaultProfile()
only injects a Default profile on fresh install, never into an existing list
- One-time migration folds legacy standalone auth_token into the Default profile,
batched into a single SharedPreferences transaction (guarded by legacy_default_migrated)
- ConnectViewModel.connect() persists via setToken/setHost/setPort (delegate to
the selected profile) — keeps the existing persistence contract
- Connect/Settings: remove the '(none)'/standalone profile option; deleteProfile
falls back to Default profile instead of null selection
- Update AuthManagerTest/ConnectViewModelTest to the unified model; fix test
stub ordering + migration re-init; remove dead connect_profile_none / settings_profile_none strings
Closes#478
* fix: stub AuthManager.ensureDefaultSelected() in E2eIntegrationTest
The #478 ConnectViewModel.connect() success branch now calls
AuthManager.ensureDefaultSelected() on the non-saveProfile path.
E2eIntegrationTest.setUp() mocks AuthManager as a non-relaxed mockkObject
but never stubs that method, so the unstubbed call throws MockKException inside
viewModelScope.launch, leaving connectionSuccess unset and failing 3 tests:
- testFullUserSessionFlow
- testConnectViewModel_onPairingString_base64Format
- testConnectViewModel_onPairingString_urlFormat
Add the missing stub. Verified locally: full suite 322/322 passing.
---------
Co-authored-by: ronia <ronia@m57>
* feat(#471): implement DataStore-based server configuration and self-healing
* refactor(#471): address Sourcery feedback on ANR risk and reset method splitting
* refactor(#471): implement synchronous ServerStore initialization to fully remove ANR risk
* feat(#471): show profile name, host and port on add profile, redirect to login on save, and support shortcut login buttons for existing profiles
* fix(#424): fully fix logout→login cycle — clear ViewModel state + manage WS lifecycle
The previous fix (PR #427) fixed button responsiveness after logout
but left two issues that kept login-after-logout broken:
1. Stale ViewModel state — AuthLoginViewModel is Activity-scoped,
so stayed from the first login. When
the user returned to the login screen, LaunchedEffect fired
instantly with the stale value and bounced them to ChatScreen
without showing the form.
Fix: DisposableEffect(onDispose) clears connectionSuccess,
errorMessage, and isLoading when the screen leaves composition.
2. WebSocket lifecycle — logout() never disconnected the WS, so
it stayed open with a null/stale token. After re-login, no one
called connect() with the fresh credentials.
Fix: HermesWsClient.disconnect() on logout,
HermesWsClient.connect() after successful re-login.
* fix(#424): route landing screen clicks through navigateTo
Avoid direct backStack.add() calls to prevent duplicate screen overlay and unresponsive buttons.
* fix(#424): close drawer when gestures are disabled
Ensure the drawer is closed when transitioning to LandingScreen or LoginScreen to prevent the scrim overlay from intercepting clicks.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Add a new NEON_NOIR theme preset with dark cyberpunk-pink aesthetic:
- Deep Void (#0f0b1a) background with Slay Pink (#ff6bf4) primary
- Full dark + light color schemes with proper contrast ratios
- Custom status colors matching the neon aesthetic
- UI dropdown entry and string resource
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>
* feat: Reorganize settings into categorized tab pages
Refactored `SettingsScreen.kt` to introduce a categorized tabbed interface using `ScrollableTabRow`, addressing issue #251. The monolithic settings scroll has been split into `Connection`, `Appearance`, `Behavior`, and `About` tabs to improve navigation and organization.
- Added `SettingsTab` enum to encapsulate tab states
- Grouped relevant `SectionCard` elements under respective tabs
- Nested Test/Save connection controls appropriately inside the connection section
- Cleaned up string resources for the newly created tabs
* feat: Reorganize settings into categorized tab pages
Refactored `SettingsScreen.kt` to introduce a categorized tabbed interface using `ScrollableTabRow`, addressing issue #251. The monolithic settings scroll has been split into `Connection`, `Appearance`, `Behavior`, and `About` tabs to improve navigation and organization.
- Added `SettingsTab` enum to encapsulate tab states
- Grouped relevant `SectionCard` elements under respective tabs
- Nested Test/Save connection controls appropriately inside the connection section
- Cleaned up string resources for the newly created tabs
- Adheres to ktlint conventions
* feat: Reorganize settings into categorized tab pages
Refactored `SettingsScreen.kt` to introduce a categorized tabbed interface using `ScrollableTabRow`, addressing issue #251. The monolithic settings scroll has been split into `Connection`, `Appearance`, `Behavior`, and `About` tabs to improve navigation and organization.
- Added `SettingsTab` enum to encapsulate tab states
- Grouped relevant `SectionCard` elements under respective tabs
- Nested Test/Save connection controls appropriately inside the connection section
- Cleaned up string resources for the newly created tabs
- Fixed UI bug by removing padding (`edgePadding = 0.dp`) in the TabRow to avoid excessive white space
- Adheres to ktlint conventions
* fix(#345): remove double padding on settings tabs
HermesScaffold already applies Scaffold's inner padding (top bar height +
status bar insets) via its own Box wrapper. The tabbed settings layout
was applying the same paddingValues a second time on the outer Column,
causing the tab row to sit ~64dp too low. Fix removes the redundant
.padding(paddingValues) call.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(#220): implement edit soul and set model dialogs
* fix(#211): make UI more compact and information dense
* fix(#211): make navigation drawer sidebar more compact
* feat(#252): move settings item to end of navigation drawer
* feat(#250): add bottom navigation bar display mode settings
* feat(#225): add search bar filtering to remaining screens
* fix: make bottom navigation bar display modes more compact
* fix: resolve compilation error in KanbanScreen by referencing assignedTo
* feat: add search to achievements screen and top padding for icon-only navigation bar
* fix: adjust icon-only bottom bar height and remove padding
* fix(#249): remove unused PaddingValues imports in 6 screens
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat: move session history to a dedicated history screen
Closes#159.
* fix(ci): fix ktlint style issues and unresolved NavigationController reference
## What
Adds a new Navigation Bar section to Settings where users can customize which items appear in the bottom navigation bar.
## Features
- Pick from any of the 19 drawer screens (max 5 items)
- Remove items with a close/delete button
- Items persist via SharedPreferences (CSV format)
- Invalid names are silently dropped
- Existing screens and auth-aware filtering respected
Closes#23
- HermesScaffold: title now @Composable () -> Unit (M3-idiomatic)
- HermesScaffold: added modifier and snackbarHost parameters
- Updated all 18 callers from title="X" to title={Text("X")}
- ChatScreen: converted from raw Scaffold to HermesScaffold
- Removed unused Menu icon import (ktlint fix)
* refactor(#5): convert SettingsScreen to use HermesScaffold
Replace raw Material3 Scaffold + manual TopAppBar with the shared
HermesScaffold wrapper. Removes ~30 lines of duplicated boilerplate
and gives SettingsScreen scroll-aware top bar behavior.
Closes#5
* fix(#5): restore ExperimentalMaterial3Api import for SegmentedButton
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
B7 (Jun 18 2026): user reported Settings page unresponsive when
reached via the ChatScreen topbar gear icon, but worked fine via
the drawer sidebar. Two fixes:
1. Route topbar gear through NavigationController.navigateTo()
instead of raw backStack.add(). The NavigationController path
(used by the drawer) deduplicates against the current top
entry and matches the drawer's navigation flow exactly. The
bypass path could stack duplicate SettingsScreen entries on
rapid taps, which likely caused the unresponsive symptom.
2. Add 'Clear Token' OutlinedButton below the Auth Token field in
Settings. One-tap wipes a corrupted / rejected token and saves
immediately, without having to select-and-delete the masked
password field character by character. Recovers from the
'I gave you a bad token' scenario in seconds.
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>