Deletes the ChatMessageList/ChatStreaming bubble path and the ChatStyle
setting (issue #866 phase 6). ChatScreen now renders FullBleedChatList
directly; ChatBubble becomes the user-message anchor with the shared
SystemBubble/InlineAttachment/formatTimestamp helpers intact. Stubs of
AuthManager.getChatStyle() removed from four test suites; TodoTaskCardTest
renders ToolBubble directly.
Verified: ktlintCheck green, testDebugUnitTest 962/962 pass, assembleDebug
APK builds.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(chat): ChatStyle preference type + store field + AuthManager (issue #866)
* feat(settings): chat style toggle in Appearance (issue #866)
* feat(chat): plumb ChatStyle into ChatUiState + refreshSettings (issue #866)
* feat(chat): full-bleed turn grouping (issue #866)
* feat(chat): full-bleed agent message renderer (issue #866)
* feat(chat): full-bleed compact tool rows + system events (issue #866)
* feat(chat): full-bleed chat list renderer + streaming (issue #866)
* feat(chat): wire full-bleed renderer behind chat style setting (issue #866)
* test(theme): full-bleed contrast gate across all presets (issue #866)
Gate caught NORD light primary/background < 3:1 — header role label
switched to onSurfaceVariant (meta token, correct for header + passes
in every preset; NORD's pastel Frost accent can't reach 3:1 on light).
* test(chat): full-bleed renderer coverage (issue #866)
* style(chat): ktlint formatting pass on full-bleed renderer (issue #866)
* fix(chat): eagerly capture per-item state in full-bleed list (issue #866)
LazyColumn item{} lambdas run at composition time, so loop locals read
inside them saw final values: turn header never rendered (firstProseSeen
always true at compose) and milestones misindexed. Capture showTurnHeader
+ milestone as immutable vals during the DSL loop. Caught by fresh-context
review (decompiled Compose foundation to confirm item composition timing).
* test(chat): stub AuthManager.getChatStyle in mockkObject setups (issue #866)
refreshSettings()/loadSettings() now read AuthManager.getChatStyle();
the four test files that mockkObject(AuthManager) with explicit stubs
had no stub for the new method, so mockk fell through to the real
object and threw 'AuthManager not initialized'. Full suite: 953/953
passing, same shape as pristine origin/main.
* feat(settings): move chat style toggle from Appearance to Chat section
* feat(chat): hoist reasoning above tool rows in full-bleed turns
Tool rows stream in before the answer message, so the turn rendered
tools above the reasoning card. The turn's reasoning now renders at
the top of the agent turn, with tool rows and prose underneath.
Streaming is folded into the turn model (groupIntoTurnsWithStreaming)
so the hoist works live while the answer is still streaming, and the
detached streaming item is gone (which also fixed the duplicate
turn-header quirk for streaming continuations).
* feat(chat): cap reasoning card height + long-press to copy
Expanded trace caps at 40% of the screen with internal scroll (Show
full lifts the cap); long-press anywhere copies the reasoning.
* feat(chat): blinking typing caret while streaming
* feat(chat): make full-bleed the default chat style
* feat(chat): remove thinking dots, show agent header above reasoning
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
- CronJob model gains optional monitor_script/monitor_url (additive-safe)
- Job editor: Monitor mode section (off/script/url) with XOR source clearing;
disabled while no-agent is set (backend invariant); new jobs apply monitor
via create -> follow-up PUT with rollback delete on failure (the REST create
model drops the fields)
- Job list/details: blocked_config renders as a distinct error badge
('Blocked (config)'), no_change as informational; monitor source shown in
run details
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(sessions): show Messages stat + slim prune button
The Active stat card always rendered 0: the mobile model declared
'val active: Int = 0', a key GET /api/sessions/stats never sends
(backend returns total/active_store/archived/messages/by_source), and
with ignoreUnknownKeys = true deserialization silently fell back to 0.
Rework the stats row instead of just key-mapping:
- Messages card replaces Active, reading the 'messages' key
(db.message_count(), web_routers/sessions.py:523-550)
- Prune becomes a slim 52dp icon-only card (contentDescription keeps it
accessible) so the Messages card keeps width for large counts
* feat(sessions): compact stat counts (100k / 1.23m) for Total & Messages
formatCompactCount renders counts as max 3 digits + unit letter:
987 -> '987', 100987 -> '100k', 1234567 -> '1.23m', 100000000 -> '100m'.
Applied to both the Total and Messages stat cards so large numbers fit
the row. Covered by SessionsStatsFormatTest (5 test cases).
* fix(sessions): label prune + empty-cleanup buttons, drop top select toggle
- Prune card now sizes to its label (icon + 'Prune' text) instead of a
52dp icon-only square, staying compact via wrap-content width.
- Empty-session cleanup becomes a labeled FilledTonalButton (icon +
'Delete empty sessions', count badge kept) instead of a bare icon.
- Removed the top-bar select toggle (SelectAll icon); selection mode
remains reachable via long-press > Select on any session row.
- Dropped now-orphaned content_desc_enter/exit_selection strings (en/ko).
* fix(sessions): prune card wrap-content width + shorter cleanup label
The prune card dropped its fixed 52dp width but kept fillMaxSize on the
inner Box — as an unweighted Row child that expands to the full row
width, starving the weighted Total/Messages cards (reported broken on
device). The Box now fills height only, so the Card wraps to its
content (icon + 'Prune').
Empty-session cleanup button label shortened to 'Delete empty'.
* fix(sessions): stack prune label under icon, cleanup button reads 'Empty'
- Prune card: trash icon above the 'Prune' label (column layout),
still wrap-content width so Total/Messages keep their space.
- Empty-session cleanup button label shortened to 'Empty'.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* 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>
* feat(sessions): empty-session cleanup button + latest-descendant resume + fresh-session 404 fix (issues #787, #840)
- Sessions top bar: Delete-empty icon with count badge, grey/disabled when 0
(GET /api/sessions/empty/count + DELETE /api/sessions/empty, confirm dialog)
- Resume flow resolves GET /api/sessions/{id}/latest-descendant first —
branched sessions continue in the newest child leaf, not the stale parent
(best-effort; falls back to the requested id)
- syncCurrentSession guarded by sessionHasServerPresence — a freshly created
(never prompted) session has no server row yet; the REST transcript 404s
and the resume retry spun forever (logcat 404 evidence)
* fix(sessions): clamp message-count fallback to backend limit cap (422)
fetchServerMessageCount requested limit=500 but the backend caps
session list page size at 100 (sessions.py Query le=100) — every
fallback scan 422'd (seen in device logcat after a branch). Sessions
are ordered recent, so the target is always in the top page.
* test(slash): make dispatch-capture assertions order-independent (CI flake)
The capture-race flake (SlashCommandDispatchRpcTest, hit 3x across CI and
local runs 2026-08-07): tests asserted on methodSlot.captured — the LAST
request captured — but the create flow's session.usage/context_breakdown
requests can land after the slash-dispatch one depending on suite order
(the class's own comment documents this cross-wiring). Converted the
three capture-based tests to capture ALL requests and find the target
record by method, with dispatch-before-fallback ordering for the 4018
test. Same assertions, no dependence on request interleaving.
* test(slash): convert remaining capture-race assertions (flake round 2)
The capture race moved to the focus/init tests (:424) after the first
three were fixed — every request-capture test in the class used the
same last-captured assumption. Converted the remaining five (init,
init+arg, focus, focus on, type=send) to the find-record-by-method
pattern; the type=send answers block reads methodCalls.last() since the
capture precedes the answer. Zero request() slot captures remain.
* test(slash): blocklist assertion counts command RPCs only (flake round 3)
The blocklisted-command test counted ALL request() calls and asserted
zero — the create flow's session.usage/context_breakdown noise can land
in the window after the stub registers (the same capture race that hit
the dispatch tests). Asserted instead that no COMMAND_DISPATCH or
SLASH_EXEC fires, which is the actual blocklist contract. E2E's static
Dispatchers mock stays (E2E VMs need IO routed to the test dispatcher —
removing it broke 20 E2E tests).
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(memory): dedicated Memory screen — providers, builtin files & reset (moved from System tab)
* feat(memory): move self-improvement & learning activity section from System tab to Memory screen
* fix(memory): remove top padding, single-line reset buttons, providers spacing
* fix(memory): tighten provider card padding
* fix(memory): distinct AutoStories icon for Memory tab
* chore: ignore local debug apk
* feat(ui): fresh icon set for drawer screens
* feat(ui): custom Neurology icon for Memory tab
* fix(ui): theme-driven fill for NeurologyIcon (checkColorLiterals)
The custom Neurology vector used SolidColor(Color.Black), tripping the
hardcoded-color guard (issue #622). The fill only contributes alpha —
Icon()'s tint replaces the RGB at draw time, mirroring Material's own
icon set — so the literal belongs in theme/ like every other hardcoded
color: new IconDefaultFill token, scoped there by the guard's design.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(toolsets): per-toolset config/env/provider/post-setup management (#782)
* fix(toolsets): per-entry toolset name for shared detail ViewModel (#782)
NavDisplay uses the activity-level ViewModelStoreOwner, so class-keyed
viewModel{} returns the same ToolsetDetailViewModel for every detail
entry; the constructor-captured name froze the first-opened toolset into
all entries (on-device: every toolset showed web's providers, no further
/config calls after the first). The screen now drives setToolset(name) +
loadConfig() per entry, and setToolset resets per-toolset state
(config, revealed secrets, running post-setup) so nothing bleeds across
toolsets.
* style(toolsets): single-line scrollable tool chip strip (#782)
A wrapping FlowRow stacked chips vertically for toolsets with many
tools (web etc.), ballooning card height — especially on large font
scales. Chips now sit on one horizontally scrollable row.
* chore(toolsets): drop stray terminal-noise line from chip strip change
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
- New-job dialog gains a Start-from dropdown: pick a catalog blueprint, fill its typed slots (time/enum/weekdays/text), save instantiates it via POST /api/cron/blueprints/instantiate
- Delivery field is now an editable picker fed by GET /api/cron/delivery-targets (origin + local + connected platforms)
- Blueprints and targets fetched on editor open; 422 slot errors surface the backend detail
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(profiles): delete, rename, auto-describe and setup command (issue #781)
* feat(profiles): use shared ModelPickerDialog for set model (issue #781)
* fix(profiles): move overflow menu to card header so it can't be clipped (issue #781)
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(kanban): live board updates over plugin events WebSocket (#775)
Tail /api/plugins/kanban/events per board with cursor tracking and
backoff reconnect; debounced REST reload on event batches (desktop's
proven pattern — event payloads are partial updates). Board switch
re-subscribes with a fresh stream. Reuses HermesWsClient's ticket
refresh (now internal). Adds ServerEndpoint.webSocketUrl(path, ...)
overload for non-chat WS endpoints.
Kanban UI pass: add-task FAB (the AddTaskDialog was unreachable),
live/offline status pill, assignee chip on task cards, 2-line
description clamp, empty-column hint.
Tests: KanbanEventsClient frame parsing, KanbanViewModel WS wiring
(connect on load, debounced reload, board-switch reconnect,
no-reconnect guard, onCleared), ServerEndpoint overload.
* fix(kanban): create task response is wrapped {"task": ...} — read it as Unit
Backend POST /api/plugins/kanban/tasks returns {"task": {...}} (wrapped),
but the app declared Response<KanbanTask> — with ignoreUnknownKeys=true
the wrapper key is skipped and the required id/title/status fields are
missing, so deserialization threw and the create showed a failure toast
while the task WAS created server-side (ghost tasks). The body is never
consumed by the ViewModel, so Response<Unit> matches updateKanbanTask.
FAB from the live-events PR (#775) made this latent bug reachable.
Adds an E2e regression assertion for the create path.
* feat(kanban): remove move left/right arrows — backend rejects the transitions
The dashboard PATCH route's status whitelist omits 'review' (a first-class
kanban_db status), so any move touching that column 400s, and reopen
transitions (done->blocked) 409. Every task on the live board sits in
'done', making the arrows 100% failure. Removing them keeps the UI honest;
tasks are managed from the desktop/CLI.
Drops moveTask/revertTaskMove from the VM, the arrows from TaskCard, the
now-dead updateKanbanTask API method and move strings; E2e test keeps
load/create coverage.
* feat(kanban): desktop-style task transitions — gated action sheet
Mirrors the desktop kanban's move model: tap a card to open an action
sheet with only the transitions the backend PATCH route accepts:
- triage / ready from any status (direct write; unblock from blocked)
- block only from running/ready, complete only from running/ready/blocked
- archive from any non-archived status
- never running (dispatcher-only, backend 400s) and never review (not in
the dashboard whitelist) — the moves that made the old arrows lie
Complete prompts for a mandatory completion summary (stored as result),
matching the desktop; block/complete/archive ask for confirmation.
Optimistic update with revert + toast on failure.
Tests: transition-gating map, move PATCH body, failure revert, completion
summary body; E2e kanban test covers a desktop-style todo->ready move.
* ui(kanban): replace oversized FAB with compact top-bar add button
The 56dp FAB floated over the board's bottom-right corner and covered a
chunk of the last column. The add action now lives in the header row next
to the live pill (input-top style), and the board's bottom contentPadding
drops back to 16dp — no more reserved dead zone.
* fix(kanban): mint an independent WS ticket — don't touch the chat's shared slot
The dashboard WS ticket is single-use and consumed at handshake. Both the
chat client and the kanban events client were refreshing into the SAME
shared AuthManager token slot on their own schedules (the kanban client on
every backoff reconnect, forever). Whichever client built its URL second
grabbed the other's just-consumed ticket, got 1008, reconnected, and stole
the other's next ticket — mutual starvation that left the app stuck at
'Reconnecting...' (repro on main: none, because main has one consumer).
HermesWsClient now exposes mintWsTicket(): a ticket minted WITHOUT touching
the shared slot (cookie-auth'd, same endpoint). The kanban events client
uses its own ticket per connection; the chat WS keeps the shared slot.
Refresh's HTTP-error -> status mapping is preserved via a small result
holder.
* fix(kanban): drop orphaned ko translations for removed move buttons
The move arrows were removed earlier but values-ko still shipped
content_desc_move_left/right — Android lint ExtraTranslation failed CI.
* fix(ws): keep RECONNECTING on transient ticket-mint exceptions
My refactor flattened the ticket-mint failure mapping: a network exception
during POST /api/auth/ws-ticket was reported as a null HTTP code and
mapped to DISCONNECTED, breaking the #740 contract that transient failures
stay in RECONNECTING (and with it testGatedMode_transientTicketFailure-
KeepsReconnectFlow). TicketRequestResult now carries an exception flag and
refreshWsTicketIfNeeded maps it to RECONNECTING.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Bring the desktop's session reconnection UX to mobile:
- Warm-cache fast-path: switchSession paints the cached Room transcript
immediately instead of blanking the screen, then refreshes from the
server in parallel (stale cache can't clobber a fresher server page)
- Bounded auto-retry: a failed session.resume (WS reject or REST
transcript failure) retries with exponential backoff 1s->2s->4s->8s,
capped at 4 attempts; WS + REST failures in the same cycle count as one
- Explicit error recovery: after exhaustion the chat shows an error card
with a manual Retry instead of latching the spinner forever; manual
retry resets the backoff cycle
- Reconnect-aware resume: gateway (re)connect clears stale error state
and re-resumes the current session on the new socket
- Unit tests: warm-cache paint, bounded retry exhaustion, WS+REST
dedupe, manual retry reset, reconnect restart
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(chat): full-screen image viewer (issue #723)
Tap any chat image (markdown , inline attachment thumbnail,
or bubble image) to open a full-screen viewer:
- pinch-zoom + pan via native detectTransformGestures
- downswipe-to-dismiss at min zoom
- Save -> device Downloads (MediaStore), Share -> Android share sheet
(both fully device-local; never sent back to the Hermes server)
Plumbing: onImageClick(ImageViewerModel) flows MarkdownText ->
ChatBubble/InlineAttachment -> ChatMessageList -> ChatScreen (state +
ImageViewerDialog). ImageBytesResolver reuses the existing authed
OkHttpProvider path for gateway http(s) sources; data:/content://
handled locally. No new dependencies.
Closes#723
* fix(chat): resolve strings at composable scope to satisfy LocalContextGetResourceValueCall lint
Lint forbids reading resource *values* from LocalContext.current inside
coroutine lambdas. Hoist all viewer strings via stringResource() at
composable scope and reuse them in onSave/onShare.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat: add Files tab backed by the managed-files endpoint
Add a Hermes Mobile tab that browses the gateway's managed-files root via the
existing /api/files* backend routes: list/navigate folders, breadcrumb path,
create folder, upload (streaming multipart), delete, and open files via the
authenticated GatewayFileClient download URL.
- data/model/ManagedFile: entry + list/read/upload/mkdir/delete models
- HermesApiService: list/read/upload(+stream)/mkdir/delete endpoints
- FilesViewModel: state, navigation, per-row busy, transient-state cleanup
- FilesScreen: folder list, breadcrumb, FAB new folder, upload picker,
delete confirm, open via system viewer
- NavigationKeys + ScreenRegistry + strings: register in Inspect drawer section
* fix(files): open files in-app via FileProvider instead of browser URL
The previous file-open path handed a ?token=-authenticated URL to the system
browser, but /api/files/download only accepts the ephemeral in-process session
token as ?token=, not the mobile's Bearer token — so the request failed and the
browser rendered the JSON error body instead of the file.
Fix: download the file in-app through the authenticated client (Bearer/
session-cookie, same path as the rest of the app), decode the base64 data_url,
write it to cacheDir, and open it via FileProvider.getUriForFile + ACTION_VIEW
(mirrors the existing ChatScreen/SystemViewModel open-from-cache pattern).
Works on a remote phone since the file is materialized locally first.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* Adopt /api/health REST route (issue #713)
Cheap liveness probe on the connection screen: GET /api/health surfaces
backend version + auth mode (Token vs Gated/OAuth) without the heavier
/api/status payload. Added HermesApiService.getHealth + HealthStatus model;
ConnectViewModel.loadHealth probes on open (best-effort, silent on failure);
ConnectScreen shows a read-only health card when reachable.
Verified: ktlint clean, assembleDebug BUILD SUCCESSFUL locally.
* Add used/full context meter to chat screen
Shows session prompt tokens vs model context window above the composer,
polled every 5s. Color-coded bar warns at 70%/90%.
* fix(chat): fix unit tests for context usage meter
Format >=100k tokens cleanly in ContextUsageChip and stub AuthManager profile ID in ChatViewModelTest.
* Fix context meter: source used tokens from input_tokens
Gateway /api/sessions/{id} has no last_prompt_tokens column; use
input_tokens instead. Show meter as soon as full context is known.
* Add tappable context breakdown sheet
Chip opens a bottom sheet with input/output/cache/read/write/reasoning
token rows + message count. Source breakdown from /api/sessions/{id}.
* Refresh context meter on streaming complete
Trigger fetchContextUsage immediately when a message finishes streaming
instead of waiting for the 5s session-sync poll.
* Fix context meter: source used tokens from live WS session.info
The REST /api/sessions/{id} endpoint reads the DB input_tokens column,
which is stale/0 for active sessions — that's why the meter was stuck at 0.
Switch the meter to the live values the gateway pushes over the WS
session.info payload after each turn:
- used = last_prompt_tokens (real used context for the turn)
- full = context_length (model-accurate window; keep /api/model/info as a
fallback before the first turn completes)
fetchContextUsage now only tops up the denominator from /api/model/info and
no longer overwrites the live WS value with the stale REST input_tokens.
The 5s poll remains as a fallback.
* Revert "Fix context meter: source used tokens from live WS session.info"
This reverts commit 932393fd80993d57e66fe93552a949460d646674.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(keys): overhaul Keys screen UI and UX
Fix card padding alignment, add delete confirmation dialog, add FAB with AddKeyDialog, support copying revealed values, add status badges, and pin restart banner.
* fix(keys): fix scaffold actions parameter and missing string resource
Resolve compilation errors in KeysScreen by removing floatingActionButton from HermesScaffold and adding missing keys_action_open_url resource.
* feat(keys): replace dropdown headers with horizontal category filter chips
Add CategoryFilterBar featuring horizontal FilterChips for quick category filtering (All, LLM Providers, Tool API Keys, Messaging Platforms, Agent Settings) with total key counts.
* style(keys): enhance key card padding, spacing, borders, and surface contrast
Use surfaceContainerLow with subtle outlineVariant border and 16dp rounded corners for high contrast cards, and set 12dp list spacing between cards.
* fix(keys): wrap key title and status badge in FlowRow to prevent layout overflow on long key names
Prevent status badge from squishing or overflowing when displaying long API key names by using a FlowRow that wraps gracefully.
* fix(keys): add file-level OptIn for ExperimentalLayoutApi
Fix compilation error by adding @file:OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) to KeysScreen.kt.
* fix(keys): fully qualify class names in file-level OptIn annotation
Fix unresolved reference build error by fully qualifying ExperimentalMaterial3Api and ExperimentalLayoutApi in @file:OptIn before package statement.
* fix(keys): remove invalid verticalAlignment parameter from FlowRow
* fix(keys): optimize long API key name readability and wrapping
Insert zero-width spaces after underscores to ensure clean word-boundary wrapping, add adaptive typography (titleSmall with 20sp line height for long names), and stack StatusBadge cleanly below the title.
* feat(keys): make API key names and values single-line and horizontally scrollable
Enforce singleLine=true / maxLines=1 with horizontalScroll(rememberScrollState()) for both key names and revealed key values so cards remain compact with uniform heights.
* feat(keys): keep single-line scrollable key values and clean multi-line key names
Keep secret key values single-line and horizontally scrollable, while restoring clean multi-line word-boundary breaking (at underscores) for key names.
* style(keys): format KeysScreen.kt with ktlint
* [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>
* feat(billing): adopt subscription.* + usage.bars WS methods (issue #628)
Add Billing/Plan screen + WS client surface for the backend release audit
(hermes-agent 0bf44d557..614dc194e). credits.view was removed upstream;
subscription.{state,preview,change,resume,upgrade} + usage.bars replace it.
- WsMethods: add SUBSCRIPTION_* + USAGE_BARS constants
- BillingModels: @Serializable request/response (snake_case, ignoreUnknownKeys)
- BillingRepository: WS dispatch via HermesWsClient.request + typed decode;
HermesRpcException (-32601) surfaces as featureUnavailable, not a crash
- BillingViewModel: plan/usage load + upgrade/change/resume/cancel actions
- BillingScreen: HermesScaffold composable (plan card, usage bars, actions)
- NavigationKeys + ScreenRegistry + strings (en/ko) wire BillingScreen into
the INSPECT drawer section
Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.
* fix(billing): align models + UI to live gateway wire shape (issue #628)
Live-probed the 6 subscription.* / usage.bars RPCs against the running
backend (hermes-agent dashboard :9119, 2026-07-19). Findings:
- subscription.state returns logged_in / current / tiers / usage (nested),
NOT the legacy subscription_type_id / renews_at fields the models assumed.
- Mutating RPCs (preview/change/resume/upgrade) return ok:false *results*
with error/message/payload (e.g. invalid_token when not portal-authed),
not RPC-level errors — so the ViewModel's HermesRpcException guard never
fired and the screen silently half-decoded null fields.
- subscription.upgrade also returns an idempotency_key not in the model.
Changes:
- BillingModels: re-shape SubscriptionStateResponse around current/tiers/
usage; add ok/error/message/payload envelope + idempotency_key to the
action responses; add SubscriptionCurrent / SubscriptionTier / preview
payload types. All nullable + ignoreUnknownKeys (graceful degrade).
- BillingViewModel: detect ok:false results on every call and route to
featureUnavailable / errorMessage instead of swallowing them.
- BillingScreen: read plan from subscription.current; render a clean
'no active plan' / 'log in to Nous Portal' card on the logged-out path
instead of a broken null plan card.
- strings: add billing_no_active_plan / billing_login_required (en + ko).
Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.
* fix(billing): address independent review (agy) — error routing + icon
Triage of the independent AGY review on PR #629:
Fixed (real issues):
- BillingViewModel: usage.bars RPC failure (-32601) no longer marks the
whole feature unavailable (was blocking the plan card despite the
'non-fatal' comment); it is now correctly non-fatal.
- BillingViewModel: a failed primary subscription load now surfaces
errorMessage even when usage bars loaded (was swallowed by
'sub == null && bars == null').
- BillingScreen: render state.errorMessage as an error card inside
BillingContent so failed mutating actions (upgrade/cancel) are visible
instead of silently disappearing.
- BillingScreen: wrap the dead AccountBalanceWallet expression in a real
Icon() composable in FeatureUnavailableState.
Declined (with rationale, recorded for maintainer):
- Tier-select / upgrade UI (review issue #4): explicitly out of scope per
the PR body — 'Plan-selection UI ... is not built ... left for a
follow-up once backend returns the available plans.'
- Rename snake_case Kotlin props to camelCase (review nit #2): incorrect —
OkHttpProvider uses JsonNamingStrategy.SnakeCase, which maps snake_case
wire keys onto snake_case properties; the props are correct. Fixed the
misleading doc comment instead.
* fix(billing): match REAL logged-in wire shapes + kill null-result crash
The live gateway probe (logged-in Nous Portal session, 2026-07-19) showed
the earlier models assumed the WRONG shape:
- subscription.state.current is {tier_id, tier_name, monthly_credits,
credits_remaining, cycle_ends_at, pending_downgrade_*} — NOT the legacy
subscription_type_id/status/renews_at fields. Screen rendered 'Free / no
plan' for a real logged-in user.
- usage.bars is {available, status, plan_name, plan_bar{remaining_display,
total_display, pct_used, fill_fraction}, topup_bar} — NOT a List<UsageBar>.
- Mutating RPCs return error:'insufficient_scope' when the Portal token
lacks billing:manage scope (ok:false result, not an RPC error).
Crash fix (root cause of 'Billing RPC returned no result payload'):
- BillingRepository.decode() now returns null on a missing/null result
instead of requireNotNull() -> IllegalStateException. All repo methods
return nullable; ViewModel handles null as a non-fatal 'No billing data
returned' instead of crashing.
- BillingScreen.PlanCard / UsageBarRow rewritten to the real fields; the
resume/cancel branch now gates on can_change_plan (no status field exists
on current anymore).
- UsageBarsResponse + UsageBar reshaped to plan_bar/topup_bar; added
billing_credits_remaining / billing_plan / billing_topup strings (en+ko).
Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL. Decode sim
against the captured live responses confirms PlanCard/UsageBarRow/error
paths all resolve with no null-crash.
* fix(billing): decode WS result that arrives as toAny() Map, not JsonElement
Root cause of 'No billing data returned' on a logged-in server: the backend
returns a valid subscription.state result, but HermesWsClient.request()
yields result already converted via JsonElement.toAny() (a Map<String,Any?>),
not a raw JsonElement. BillingRepository.decode() only accepted JsonElement,
so was null and the repo returned null -> ViewModel showed
the generic 'No billing data returned' message. The server DOES have billing
(logged_in:true, tier_name:Free, /usr/bin/bash.10 credits) — the app just couldn't parse it.
Fix: decode() now normalizes whatever arrives — a kotlinx JsonElement OR the
toAny() Map/List — back into a JsonElement via a recursive Any->JsonElement
walk, then deserializes. Both shapes decode correctly now.
This also means the previous 'Billing RPC returned no result payload' crash
and the generic 'no data' message were both symptoms of this same parse gap,
not a missing backend feature.
Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL.
* perf(analytics): preload on launch + split-fetch + disk cache (issue #537)
Cut perceived analytics-tab latency for every user regardless of backend
DB size:
A. Preload analytics (7/30/90) in the background on app launch
(AnalyticsPreloader) so the tab renders instantly when opened.
B. Split the two endpoint fetches: /models (~0.1s, carries the
totals card) now renders immediately while the slow /usage (insights
engine over full message history) streams in behind a placeholder.
C. Persist last successful response to DataStore so a cold app launch
serves last session's data instantly, then refreshes (AnalyticsCacheStore).
Root cause of the slowness is server-side (/api/analytics/usage cold
read of a multi-GB state.db); these client changes make the common
case instant and never block the UI on the slow call.
* fix(analytics): address agy review — null-safety, profile-keyed cache, no main-thread block
Triage of independent agy review (High model) on PR #630:
Fixed (real):
- Blank-on-cold-launch: AnalyticsContent early-returned on null
usage, blanking the tab while the slow /usage call loaded. Now
renders the fast /models half + placeholders; usage-only cards
(totals, daily chart) and Skills/Tools tabs guard on usage != null.
- Profile isolation: cache was keyed by days only -> cross-profile
data leak on switch/cold-start. Now keyed "$days:$profile".
- Main-thread block: ViewModel.init used runBlocking(IO) on the
UI thread at startup. Moved disk-seed into viewModelScope.launch.
- Serialization parity: cache used bare kotlinx Json; switch to
OkHttpProvider.json (ignoreUnknownKeys) so a backend field
addition can't break on-disk decode.
Declined (not introduced by this PR / protected by viewModelScope):
- "safeApiCall swallows CancellationException": pre-existing
NetworkResult.kt behavior; viewModelScope cancellation already
prevents post-clear state updates. Out of scope for this change.
* fix(analytics): crash on tab open — LocalContext is Activity, not Application
AnalyticsScreen cast LocalContext.current as Application, but under
Compose the composition context is the *Activity*, so the cast threw
ClassCastException the moment the tab composed (on tap). Use
.applicationContext, which resolves to the Application instance.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(billing): adopt subscription.* + usage.bars WS methods (issue #628)
Add Billing/Plan screen + WS client surface for the backend release audit
(hermes-agent 0bf44d557..614dc194e). credits.view was removed upstream;
subscription.{state,preview,change,resume,upgrade} + usage.bars replace it.
- WsMethods: add SUBSCRIPTION_* + USAGE_BARS constants
- BillingModels: @Serializable request/response (snake_case, ignoreUnknownKeys)
- BillingRepository: WS dispatch via HermesWsClient.request + typed decode;
HermesRpcException (-32601) surfaces as featureUnavailable, not a crash
- BillingViewModel: plan/usage load + upgrade/change/resume/cancel actions
- BillingScreen: HermesScaffold composable (plan card, usage bars, actions)
- NavigationKeys + ScreenRegistry + strings (en/ko) wire BillingScreen into
the INSPECT drawer section
Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.
* fix(billing): align models + UI to live gateway wire shape (issue #628)
Live-probed the 6 subscription.* / usage.bars RPCs against the running
backend (hermes-agent dashboard :9119, 2026-07-19). Findings:
- subscription.state returns logged_in / current / tiers / usage (nested),
NOT the legacy subscription_type_id / renews_at fields the models assumed.
- Mutating RPCs (preview/change/resume/upgrade) return ok:false *results*
with error/message/payload (e.g. invalid_token when not portal-authed),
not RPC-level errors — so the ViewModel's HermesRpcException guard never
fired and the screen silently half-decoded null fields.
- subscription.upgrade also returns an idempotency_key not in the model.
Changes:
- BillingModels: re-shape SubscriptionStateResponse around current/tiers/
usage; add ok/error/message/payload envelope + idempotency_key to the
action responses; add SubscriptionCurrent / SubscriptionTier / preview
payload types. All nullable + ignoreUnknownKeys (graceful degrade).
- BillingViewModel: detect ok:false results on every call and route to
featureUnavailable / errorMessage instead of swallowing them.
- BillingScreen: read plan from subscription.current; render a clean
'no active plan' / 'log in to Nous Portal' card on the logged-out path
instead of a broken null plan card.
- strings: add billing_no_active_plan / billing_login_required (en + ko).
Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.
* fix(billing): address independent review (agy) — error routing + icon
Triage of the independent AGY review on PR #629:
Fixed (real issues):
- BillingViewModel: usage.bars RPC failure (-32601) no longer marks the
whole feature unavailable (was blocking the plan card despite the
'non-fatal' comment); it is now correctly non-fatal.
- BillingViewModel: a failed primary subscription load now surfaces
errorMessage even when usage bars loaded (was swallowed by
'sub == null && bars == null').
- BillingScreen: render state.errorMessage as an error card inside
BillingContent so failed mutating actions (upgrade/cancel) are visible
instead of silently disappearing.
- BillingScreen: wrap the dead AccountBalanceWallet expression in a real
Icon() composable in FeatureUnavailableState.
Declined (with rationale, recorded for maintainer):
- Tier-select / upgrade UI (review issue #4): explicitly out of scope per
the PR body — 'Plan-selection UI ... is not built ... left for a
follow-up once backend returns the available plans.'
- Rename snake_case Kotlin props to camelCase (review nit #2): incorrect —
OkHttpProvider uses JsonNamingStrategy.SnakeCase, which maps snake_case
wire keys onto snake_case properties; the props are correct. Fixed the
misleading doc comment instead.
* fix(billing): match REAL logged-in wire shapes + kill null-result crash
The live gateway probe (logged-in Nous Portal session, 2026-07-19) showed
the earlier models assumed the WRONG shape:
- subscription.state.current is {tier_id, tier_name, monthly_credits,
credits_remaining, cycle_ends_at, pending_downgrade_*} — NOT the legacy
subscription_type_id/status/renews_at fields. Screen rendered 'Free / no
plan' for a real logged-in user.
- usage.bars is {available, status, plan_name, plan_bar{remaining_display,
total_display, pct_used, fill_fraction}, topup_bar} — NOT a List<UsageBar>.
- Mutating RPCs return error:'insufficient_scope' when the Portal token
lacks billing:manage scope (ok:false result, not an RPC error).
Crash fix (root cause of 'Billing RPC returned no result payload'):
- BillingRepository.decode() now returns null on a missing/null result
instead of requireNotNull() -> IllegalStateException. All repo methods
return nullable; ViewModel handles null as a non-fatal 'No billing data
returned' instead of crashing.
- BillingScreen.PlanCard / UsageBarRow rewritten to the real fields; the
resume/cancel branch now gates on can_change_plan (no status field exists
on current anymore).
- UsageBarsResponse + UsageBar reshaped to plan_bar/topup_bar; added
billing_credits_remaining / billing_plan / billing_topup strings (en+ko).
Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL. Decode sim
against the captured live responses confirms PlanCard/UsageBarRow/error
paths all resolve with no null-crash.
* fix(billing): decode WS result that arrives as toAny() Map, not JsonElement
Root cause of 'No billing data returned' on a logged-in server: the backend
returns a valid subscription.state result, but HermesWsClient.request()
yields result already converted via JsonElement.toAny() (a Map<String,Any?>),
not a raw JsonElement. BillingRepository.decode() only accepted JsonElement,
so was null and the repo returned null -> ViewModel showed
the generic 'No billing data returned' message. The server DOES have billing
(logged_in:true, tier_name:Free, /usr/bin/bash.10 credits) — the app just couldn't parse it.
Fix: decode() now normalizes whatever arrives — a kotlinx JsonElement OR the
toAny() Map/List — back into a JsonElement via a recursive Any->JsonElement
walk, then deserializes. Both shapes decode correctly now.
This also means the previous 'Billing RPC returned no result payload' crash
and the generic 'no data' message were both symptoms of this same parse gap,
not a missing backend feature.
Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Turn dead-end empty states into on-ramps by wiring the existing
EmptyState actionLabel/onAction params to real next actions.
Screens wired (real, existing actions):
- Skills (installed): Browse skill hub -> setViewMode(HUB)
- Skills (hub): Retry search -> searchHub(hubQuery)
- Cron Jobs: Create your first job -> openNewJobDialog()
- MCP servers: Add a server -> toggleAddForm() (literal moved to strings.xml)
- Webhooks: Create subscription -> showCreateDialog() (literal moved to strings.xml)
- Channels: Connect a Telegram channel -> startTelegramOnboarding()
- Model: Configure model -> navigateTo(SettingsConnection)
- Sessions (history): Start a new chat -> navigateTo(ChatScreen)
- Analytics: Explore Skills or Cron jobs -> navigateTo(SkillsScreen)
- Keys (no match): Clear search -> query = ""
All new labels use strings.xml (empty_action_* / screenname_empty_*).
Deviations from issue (documented honestly):
- Toolsets, Profiles, Plugins keep Refresh. No in-app create path exists
(toolsets are server-defined toggles; profiles/plugins have no create
dialog wired in UI). Refresh is the correct recovery there, not a fake
'create' CTA. Flagged for follow-up if create flows get built.
- Skills: edited the real private InstalledSkillsView in SkillsScreen.kt;
the duplicate internal one in InstalledSkillsTab.kt is dead code.
Verified: ktlintCheck clean, assembleDebug green.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Co-authored-by: Ronia <ronia@hermes>
Independently verified dead weight: computed defined vs used set across
all source sets (R.string.* in Kotlin + @string/* in XML/manifest).
Zero references in app/src/main, androidTest, or test fixtures.
- values/strings.xml: -77 unused <string> entries (818 -> 741)
- values-ko/strings.xml: -67 corresponding entries (704 -> 637)
Build: ./gradlew assembleDebug BUILD SUCCESSFUL (no missing-resource errors).
True KO translation gap after prune = 741 used EN - 637 translated KO = 104
(previously mis-stated as 114; the 67 removed were never translated anyway).
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#606): support auth mode + bearer token when adding HTTP MCP servers
- Extend AddMcpServerRequest with auth (none|header|oauth) and bearerToken
- Add auth mode FilterChip selector in HTTP add-server dialog
- Conditionally show masked bearer token field when auth == header
- Wire new fields through McpServersViewModel request builder
Backend contract: exactly one of url/command required; bearer_token
requires auth == "header"; args/env only for stdio. SnakeCase naming
strategy auto-serializes bearerToken -> bearer_token.
Fixes#606
* fix(#606): address agy review — remember PasswordVisualTransformation + FlowRow for auth chips
- Wrap PasswordVisualTransformation in remember {} to avoid per-recomposition allocation
- Use FlowRow for the 3 auth-mode FilterChips so they wrap on narrow screens instead of overflowing
* refactor(settings): drill-down navigation replacing tabbed layout
Replaces the 4-tab settings layout with a native Android-style drill-down:
a single category list (SettingsScreen) where each row pushes a dedicated
sub-page onto the Navigation3 back stack (SettingsConnection / Appearance /
Chat / NavBar / Behavior / About). Each sub-page is its own NavKey destination
registered in Navigation.kt and reuses the existing section composables.
- SettingsScreen becomes the category list with icon + label + summary
(Appearance row shows live theme + language, e.g. "Dark · 한국어").
- New SettingsSubPages.kt hosts the 6 full-screen sub-pages.
- AboutSection promoted out of SettingsScreen into its own sub-page.
- SettingsViewModel remains the single source of truth; no persistence changes.
- Adds settings_summary_* strings for list subtitles.
Consumes the in-app language switcher from the prior commit (AppearancePage
passes appLanguage through to AppearanceSection).
* [bug] fix settings sub-page back button opening drawer on first tap
The ModalNavigationDrawer wraps the whole app and stays gesture-enabled on
the Settings root (a DRAWER_GESTURE_SCREEN). Drilling into a sub-page
(SettingsConnection/Appearance/Chat/NavBar/Behavior/About) disables gestures,
but the existing LaunchedEffect only snapped the drawer closed when it was
already isOpen. The drawer's scrim surface lingered during the transition and
intercepted the first back-button tap, sliding the sidebar open instead of
navigating back.
Now snapTo(Closed) unconditionally whenever gestures are disabled — a safe
no-op when already closed — so the sub-page back button works on the first tap.
* [bug] fix settings sub-page back button opening drawer (gesture-surface race)
The first "back" tap on a settings sub-page opened the sidebar instead of
navigating back. Root cause: Settings root is a gesture-enabled drawer screen,
and the sub-pages are the first destinations ever drilled down from it. When
navigating in, ModalNavigationDrawer's gesture interceptor surface lingered
for a frame (gesture -> non-gesture transition), so the top-left tap was
consumed by the drawer instead of the Back IconButton. The previous snapTo
fix only reset drawer *state*, not the gesture layer, so it didn't help.
Fix: NavigationController now holds a closeDrawer hook (set by MainNavigation)
and invokes it synchronously inside navigateTo() when pushing a non-primary
sub-page. The drawer is dismissed in the same tap handler that triggers the
navigation, before any recomposition, so the sub-page is never tappable while
the gesture surface is still mounted. The LaunchedEffect(gesturesEnabled)
snapTo remains as a backstop for non-navigateTo entries.
* fix(#603): delay drawer gesture activation to prevent residual swipe
* fix(#603): fix settings back navigation and drawer auto-open
---------
Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
Add a Language selector to Settings → Appearance that overrides the app
display language independent of the device locale. Persists a BCP-47 code
in ServerStoreState.appLanguage; MainActivity.attachBaseContext applies it
via a ContextWrapper (works on minSdk 26, no AppCompat dependency needed
since the Activity is a plain ComponentActivity). Selecting a language
recreates the activity so resources re-resolve immediately.
Includes System default / English / 한국어 options, plus a unit test for
the locale-code resolver.
Note: the new string keys (settings_item_language, language_system,
language_english, language_korean) should be backfilled into values-ko
once PR #602 lands.
Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
Allow the chat input to be sent while the agent is mid-turn or awaiting
approval. The backend's prompt.submit busy-input policy already queues any
prompt that lands during a running turn (tui_gateway/server.py:_handle_busy_submit)
and drains it as the next turn, so the message is never dropped.
- ChatInputPolicy: pure, testable send/placeholder decision logic
- ChatScreen: canSend stays enabled while typing/approving; placeholder shows
'Queued — sends when agent is free' when text is present during a busy turn
- ChatInputPolicyTest: truth-table covering typing, approval-pending,
disconnected, empty, attachment, and slash-command cases
- strings: chat_input_placeholder_queue
Note: this supersedes the Genesi599 fork's approach (dispatchViaRpc('queue ...')),
which double-submits and relies on a 4018-prone command.dispatch path. The
backend-queued prompt.submit path is the correct, single-submission contract.
Co-authored-by: M57 <m57@hermes-control.app>
* feat(#530): add SessionSearch models + searchSessions endpoint + contract tests
* feat(#530): wire server-backed search into Sessions screen (debounce + render)
* style(#530): ktlint import ordering on HermesApiService
* fix(#530): ktlint-clean overlap wrap + map search snippet to title
* fix(#530): map search snippet to title so results show + highlight
* fix(#530): render search hits as honest result cards instead of faking title
Backend SessionSearchResult carries no session name (only snippet + metadata),
so mapping snippet->title made the card show raw excerpt as the title. Instead,
render a dedicated SearchResultCard: 'Match' label + session id, highlighted
snippet as body, and source/model/played-at chips. Added SessionInfo.model and
a friendly relative-time formatter. ktlint clean, compileDebugKotlin + unit
tests pass.
* fix(#530): strip JSON from search snippet + tighten result card padding
* fix(#530): strip FTS highlight markers from search snippet on client
agy's backend change re-wraps matched terms in >>>term<<< markers inside the
clean snippet, but the mobile highlights by raw query term and never consumed
those markers — so literal '>>>'/'<<<' would render in result text. Strip the
markers before passing the snippet to highlightText so the query highlight
still works on clean text.
* fix(#530): integrate tree-flattening hierarchy with search modes in SessionsScreen
* fix(#530): fix test assertion and mock ApiClient in SessionsViewModelTest
* fix(#530): resolve compose PaddingValues compile error and fix slash-preservation test path
* fix(#530): clean search snippet from JSON payload and add unit test
---------
Co-authored-by: M57 <m57@hermes-control.app>
* fix(#548): stop System tab button text truncation + fix two wrong labels
* fix(system): remove inline icons from narrow system action buttons to prevent text cutoff
* fix(system): restore action icons and implement dynamic font scaling for button text
* fix: stack icon above text in system buttons to prevent label clipping
Replace horizontal Icon+Text layout with vertical Column stacking
(icon on top, text centered below) — the same pattern Material 3
NavigationBar items use. Gives text the full button width so labels
like 'Security Audit' and 'Config Migrate' never get cut off.
* fix: add missing ImageVector import for system buttons fix
---------
Co-authored-by: M57 <m57@hermes-control.app>
Previously mobile dropped the gateway's sudo.request and secret.request
events, so any agent turn needing a sudo password or a secret value hung
forever with no UI. Now:
- EventParser maps sudo.request / secret.request -> WsEvent.SudoRequest / SecretRequest
- ChatViewModel surfaces a secure dialog (SudoPromptUi / SecretPromptUi) and
replies via sudo.respond / secret.respond with session_id + request_id + payload
- ChatWsEventReducer explicitly branches the new events (no-op, VM-owned)
- ChatScreen renders password-masked SudoPromptDialog / SecretPromptDialog
- 6 unit tests cover prompt-state, RPC params, and prompt-clearing
Mirrors desktop/web contract (apps/shared GatewayEventName + tui_gateway).
Derives from audit of /opt/hermes-agent desktop app.
* feat(#500): tap-to-reveal detail dialog for truncated cards
Add shared DetailDialog composable and wire tap-to-open full metadata
dialogs across Skills (installed + hub), MCP servers, Plugins, Toolsets,
and Webhooks screens. Action buttons stay on the card (sticky); tapping
the card body reveals untruncated description + key metadata.
Refs: #500
* fix(#500): resolve compile errors from missing runtime imports + dialog scope
- add missing androidx.compose.runtime.mutableStateOf/remember imports
in InstalledSkillsTab + HubSkillsTab (caused 'Unresolved reference'
cascade on compileDebugKotlin)
- move PluginsScreen detail dialog inside the composable function scope
(was nested in OrphanPluginCard -> showDetail unresolved)
- DetailDialog rows type: Pair<String?,String?> -> Pair<String,String?>
(key always non-null; fixes type-inference failure)
- drop unnecessary safe call on non-null label
Verified locally with the Android SDK:
./gradlew :app:compileDebugKotlin -> BUILD SUCCESSFUL
./gradlew :app:testDebugUnitTest -> BUILD SUCCESSFUL
Refs: #500
* refactor(#500): extract toDetailRows() mappers + semantic status tones; wire SkillsScreen hub browse
- add DetailRows.kt with @Composable extension mappers for Skill/HubSkill/
McpServer/PluginInfo/Toolset/WebhookSubscription -> List<DetailRow>
- DetailDialog now renders status rows via LocalHermesStatusColors (review #2):
SUCCESS/NEUTRAL/WARNING/ERROR/INFO tones instead of plain onSurfaceVariant
- wire SkillsScreen.HubBrowseView/HubSkillCard tap -> DetailDialog (same UX as
other screens) — user request
- remove JVM-signature-clashing Pair overload; drop unused Color import
- add detail_dialog_trust_level string (HubSkill has no version field)
- verified: compileDebugKotlin + testDebugUnitTest PASS locally w/ ANDROID_HOME
* fix(#500): full hub description in dialog + no name truncation
- HubBrowseView (both SkillsScreen + HubSkillsTab): on card tap, fetch
previewHubSkill(identifier) to get the FULL SkillContentResponse.content
(search snippet was truncated); dialog shows full content once loaded,
with a spinner while previewing. Falls back to snippet if no identifier.
- SkillsViewModel: add hubPreviewContent/isHubPreviewing/hubPreviewError
state + previewHubSkill()/clearHubPreview() (uses existing preview endpoint)
- DetailDialog: title now wraps fully (removed maxLines=2 ellipsis cap) so
long skill names are never cut off
- verified: compileDebugKotlin PASS + ktlint clean (local ANDROID_HOME)