* fix(#724): inline host-path MEDIA: images on mobile (client-side)
The previous approach re-fetched the REST session-messages endpoint, but that
endpoint returns the raw MEDIA:<path> text (api_server._message_response does
not inline, and the transcript stores raw content). The desktop app instead
reads the file locally via readFileDataUrl because it is co-located with the
host. Mirror that on mobile: on assistant message.complete/done, inline any
MEDIA:<path> image into a base64 data:image/... markdown tag the renderer can
show. Mobile-only, backend untouched. Unreachable tags are stripped (graceful
degrade). Pure inlining logic extracted to MediaInline for unit testing.
* fix: correct RegexOption name (IGNORE_CASE) in MediaInline
* test: fix byte literal types in MediaInlineTest
* fix(#724): rewrite host-path MEDIA: to gateway /api/files/download URL
Pivot from the earlier base64-inline approach to the desktop-proven,
remote-phone-working fix: rewrite MEDIA:<path> directives (delivered raw over
the WS stream) into authenticated
 markdown tags,
mirroring desktop media.ts mediaExternalUrl. The mobile renderer (MarkdownText)
shows http(s) ![]() via Coil, so agent-sent images now render on mobile too —
including on a remote phone (real HTTP, unlike a host-local file read).
Mobile-only, backend untouched. Pure rewrite logic in MediaUrlRewriter
(replaces MediaInline) with unit tests.
* feat(#724): full gateway-file support — attach MEDIA: files (not just images)
Pivot the MEDIA: fix from image-only URL rewriting to real attachments:
- Attachment: add gatewayUrl + AttachmentSource (LOCAL/GATEWAY) so
gateway-hosted files are first-class and render via existing InlineAttachment.
- HostMediaExtractor (replaces MediaUrlRewriter): parse ANY absolute MEDIA:
directive (any extension) and strip them from message text.
- ChatViewModel.attachHostMedia: build the authed /api/files/download URL via
GatewayFileClient, classify via MediaTypes, attach as Attachment (images
inline, every other file tappable). Idempotent.
- InlineAttachment: tappable. Gateway files fetch via GatewayFileClient and
share via a chooser Intent (FileProvider); images load the URL in Coil.
- Hence agent-delivered images, audio, video, CSV, PDF and arbitrary files all
work on mobile — including a remote phone. Backend untouched.
Builds on #726 (GatewayFileClient). Rebased onto main after #726 merged.
* fix(#724): resolve imports, bubble callback scope, and quoted media path regex
* feat(chat): open attachment in-place on tap (#724)
Tap opens LOCAL (content://) and GATEWAY (fetch->FileProvider) files via
ACTION_VIEW; failures surface as a non-blocking snackbar via openError.
* fix: remove redundant out-of-order io.mockk.* wildcard import (ktlint CI)
ktlint import-ordering requires lexicographic order; the trailing wildcard
'import io.mockk.*' sorts before 'io.mockk.coEvery' and was redundant — every
mockk symbol is already explicitly imported, and the single mockkConstructor
call uses a fully-qualified io.mockk. prefix (needs no import).
Fixes ktlint failure on PR #725.
* fix(ci): mock android.jar Intent stub so openAttachment GATEWAY test passes
android.jar unit-test stubs Intent.setDataAndType/addFlags to throw
'... not mocked'. openWithView builds an Intent and calls them, so
openBytes' runCatching swallowed the exception and startActivity never
fired, failing the test. Mock the Intent constructor + setters and
capture the constructed instance to assert the ACTION_VIEW contract.
* fix(#724): resolve host media download URLs and deduplicate REST synced chat bubbles
* fix(#724): add FLAG_ACTIVITY_NEW_TASK for file intents and update GatewayFileClientTest
---------
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>
* fix(#686): implement robust history pagination
Honor server-returned offset, degrade gracefully on older servers, and parse JSON tool results.
* fix(ci): repair unit-test compilation and pagination test mocks
- Remove trailing blank line in SessionMessagesResponse.kt (ktlint)
- Replace unresolved io.mockk.eq/coAnswers usage in ChatViewModelTest with
an ordered-call counter inside coAnswers, which compiles and runs on
MockK 1.14.11 (eq is a matcher-scope member, not a top-level import)
- Align HermesApiServiceMockWebServerTest with the PR's JsonElement content
model (contentText) and the new include_compacted=true default query param
All unit tests pass locally with the ByteBuddy agent preload.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix: correct bulk session delete endpoint + surface deleted count
- Fix Retrofit @POST path from 'api/sessions/delete' to 'api/sessions/bulk-delete'
- Change response type from Response<Unit> to Response<BulkDeleteResponse>
- Add BulkDeleteResponse model (ok, deleted) matching backend contract
- Surface backend 'deleted' count in toast message instead of using client-side count
Backend contract (web_server.py:10750):
POST /api/sessions/bulk-delete
Body: { "ids": [...], "delete_all": false }
Response: { "ok": true, "deleted": <count> }
Fixes#651
* fix: replace client-side session list filter with full refresh after bulk delete
- Call loadSessions() + loadStats() after successful bulk delete instead of
filtering sessions/searchResults locally via 'ids' — fixes state desync
when backend deletes fewer sessions than requested (partial success) and
is correct for delete_all=true where ids may be empty
- Show 'No sessions were deleted' toast when deletedCount == 0
- Extract toastMsg to separate val to satisfy ktlint lint rules
Addresses review findings: UI state desync, delete_all handling, hardcoded
pluralizing, and zero-deletion UX.
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
Verified against backend source at tip 299e409f15 (tui_gateway/server.py).
- SubscriptionCurrent: add cancel_at_period_end / cancellation_effective_at / cancellation_effective_display (emitted by _serialize_subscription_state, were missing on mobile).
- SubscriptionTier: align to wire shape {tier_id, name, tier_order, dollars_per_month_display, monthly_credits, is_current, is_enabled}; old {tier_name, price, currency, interval} never matched the backend and is unused in-app.
- NOTE: usage.bars UsageBar *_display string model is CORRECT — the backend serializes raw *_usd floats to *_display strings via _serialize_usage_bar before sending. Issue #631 misidentified this as a gap; switching to *_usd would break the bars. No change made there.
Co-authored-by: ronia <ronia@m57>
* 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>
* 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
* 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>
* 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
* refactor: migrate Gson serialization to kotlinx.serialization
* fix(dto): resolve serializing Any? and Map<String, Any?> with JsonElement
* test(refactor): fix unit test issues under kotlinx.serialization and update nullable DTO defaults
* fix(chat): support flexible numeric and string decoding for message timestamps
* fix(chat): sequentialize cache loading to avoid overwriting network state
* fix: correct FilterChipRow and EmptyState API mismatches + ktlint fmt
* fix: add missing Save import, fix verticalArrangement double-wrapping
* fix: address Sourcery review — strings, enums, hubQuery, StatusColors
• Hardcoded strings → stringResource (Installed, Browse Hub, Statuses, All sources)
• Magic string filters → SkillFilter enum + CATEGORY_ALL constant
• FilterChipRow made generic with chipLabel parameter
• HubBrowseView hubQuery driven from ViewModel state (no dupe)
• ViewModel toasts use getString(R.string.*) via AndroidViewModel
• MaterialTheme.colorScheme.error → LocalHermesStatusColors.current.error
* fix: OpenDrawer → Menu, listContentPadding is PaddingValues not ext
NavIcon.OpenDrawer doesn't exist, use NavIcon.Menu. listContentPadding is a val PaddingValues, not a Modifier extension — use Modifier.padding(listContentPadding).
* fix: NavIcon.Menu(onClick) -> NavIcon.Menu(onOpen)
* fix: pass mockApp to SkillsViewModel in unit tests (now AndroidViewModel)
* fix: mock getString in unit tests for AndroidViewModel string resources
* fix: anyVararg<Any?>() → anyVararg<Any>() to satisfy type bound
anyVararg<T> has bound T : Any, so Any? doesn't compile.
Any is a subtype of Any? at the JVM level, so the matcher
works correctly for vararg Any? parameters.
* fix: use anyVararg<String>() for getString mock to match actual runtime types
The previous anyVararg<Any>() did not match getString(Int, Object...) vararg
calls at runtime — mockk's relaxed mock returned '' for all formatted string
resources, causing all Skills error-path assertions to fail (9 tests).
Root cause: mockk vararg matchers match by reified element type. The Java
method Context.getString(int, Object...) passes format args as String at
runtime (via Kotlin's spread). anyVararg<Any>() with Any (boxed) doesn't
match Object/String vararg elements reliably, but anyVararg<String>() does,
since String satisfies the T : Any bound and matches the actual runtime type.
Also added getString(Int) stub for the no-arg overload used by enums.
* fix: stub getString per-resource-ID to bypass mockk Java vararg bug
mockk has a known bug matching Java vararg methods like
Context.getString(int, Object...) with anyVararg<T>() — the matcher
silently fails to match, and the relaxed mock returns '' for all
formatted string resource calls. This caused 8 Skills error-path tests
to fail (errorMessage/toastMessage was '' instead of containing 'HTTP 500').
Root cause: confirmed via test report artifacts — errorMessage was
non-null but empty string, proving the vararg stub never matched.
Fix: replace the generic anyVararg<String>() stub with explicit per-
resource-ID stubs using eq() + any<String>() matchers. Each formatted
string resource is stubbed individually, returning the format arg
(the dynamic error message) so the test assertions find 'HTTP 500'
and 'Timeout' substrings.
References:
- https://stackoverflow.com/questions/54326914/using-mockk-to-match-any-varargs
- https://github.com/mockk/mockk/issues/224
* fix(skills): replace AndroidViewModel getString() with hardcoded strings to fix unit tests
The SkillsViewModel was using getApplication<Application>().getString() for
error/success messages. In unit tests, mockk cannot reliably stub Java vararg
Context.getString(int, Object...), causing NullPointerException inside
coroutine onError callbacks. All other ViewModels in the codebase use hardcoded
strings; this change aligns SkillsViewModel with that convention.
* test: add hub search/install/uninstall/preview coverage + rm vestigial stubs
- Remove unused mockApp.getString() per-resource-ID stubs (SkillsViewModel
no longer calls getApplication().getString() — hardcoded strings now).
- Add 10 tests covering hub search (success/empty/error/emptyQuery),
hub install (success/error), hub uninstall (success/error), and skill
preview (success/error). Uses existing mockk + runTest patterns.
* fix: correct import ordering for HubSkill (H < S)
Move HubSkill import before KanbanBoard (H < K) and remove duplicate
that was between SessionListResponse and Skill.
* test: remove coVerify from emptyQuery test (cross-test mock state)
* fix: remove double-stacked padding in SkillsScreen (HermesScaffold handles it)
Per AGENTS.md — HermesScaffold already offsets for the top bar
internally, so .padding(padding) on inner content creases double-stacked
top padding.
* refactor: merge preview + edit button in SkillCard into single adaptive action
Per AGENTS.md — two nearly-identical icon buttons (Preview + Edit)
side by side was confusing. Merged into one Edit button since
both opened the same skill content dialog (editable for installed,
read-only for hub is handled by the ViewModel).
* fix: revert onCategoryChange to non-null String (FilterChipRow<T> needs it)
Also removes leftover Preview icon import that ktlint flagged.
* refactor: restructure SkillCard layout — name gets full width, actions below
The old layout crammed icon, name, source badge, edit button, uninstall
button, AND toggle switch all on one horizontal row. Long skill names
got truncated to 2-3 characters.
New layout:
- Top row: icon | name (weight 1f) | source badge | toggle switch
- Below: category (if present)
- Below: description (if present)
- Bottom row: edit + uninstall buttons (end-aligned, only if hub source)
The name Text now has weight(1f) so it fills all available space.
* fix: fix brace nesting + move icon to far left for extra name space
* fix: add missing height import
* refactor: remove Extension icon from SkillCard for cleaner layout
* fix: remove double-stacked padding in SkillEditorDialog and SkillPreviewDialog
Both dialogs had the AGENTS.md footgun: HermesScaffold already offsets
the top bar internally, but the inner Box had .padding(padding) stacking
an extra ~56dp gap between the header and the edit text field.
* fix(skills-hub): unwrap SkillHubSearchResponse wrapper
Backend changed /api/skills/hub/search from returning a raw JSON
array to an object wrapper: { results: [...], source_counts: {...}, ... }
- Added SkillHubSearchResponse, SkillHubInstalledEntry models
- Extended HubSkill with identifier, trustLevel, repo, tags
- Updated API endpoint return type to SkillHubSearchResponse
- ViewModel unwraps data?.results.orEmpty() instead of data.orEmpty()
* fix: unwrap SkillHubSearchResponse in ProfilesViewModel too + remove unused HubSkill import
* fix: update test mocks to use SkillHubSearchResponse wrapper
* fix: refactor HubSkillCard layout — remove icon, name gets full width, install below
* fix: use unique key for hub results + weight(1f) to prevent scroll crash
* fix(skills): resolve crash due to duplicate keys in lazy list
* feat(skills): move search hub button into trailing icon of search bar
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#442): categorized env keys screen with add/remove, search, and gateway restart
- Group env vars by category (LLM Providers, Tool API Keys, Messaging
Platforms, Agent Settings, Other) with collapsible AnimatedVisibility
sections matching MCP screen pattern
- Add new env var form (PUT /api/env) with key/value inputs
- Delete env var via DELETE /api/env with EnvVarDeleteRequest body
- Reveal/hide/edit inline per-card controls
- Search bar filtering across key names and descriptions
- Gateway restart banner with one-click action when changes are dirty
- Track keysChanged flag showing restart prompt after mutations
- Wire delete endpoint in HermesApiService using @HTTP(hasBody=true)
- Add EnvVarDeleteRequest data model class
* fix(#442): update test to match new categorized KeysViewModel API
* fix(#442): satisfy ktlint multiline-expression-wrapping rule
---------
Co-authored-by: Hermes Agent <hermes@agent.local>
* feat(#444): add MCP API endpoints and data models
- Add McpServer.status and McpServer.error fields for diagnostics
- Add AddMcpServerRequest, McpCatalogResponse/Entry/EnvVar models
- Add McpServerTestResponse model
- Add POST addMcpServer, PUT updateMcpServer, POST restartMcpServer
- Add GET getMcpCatalog, POST installMcpCatalogEntry
* feat(#444): add server form, enriched server cards, catalog section
- Add HTTP/stdio inline server form with tabs
- Enriched server cards with status badge, restart, env var editing
- Add/edit/remove environment variables per server
- Catalog section with search, result cards, install flow with env vars
- Status badge colors: running=green, error=red, stopped/gray
- Server diagnostics shown as error badges on cards
- New string resources for all MCP UI elements
* fix(#444): add missing McpServer import to HermesApiService
* fix(#444): cleanup review issues — dead code, spinner, unused strings
- Remove unused serverDiagnostics from UiState
- Remove unused showDiagnostics local state in ServerCard
- Remove unused McpServerTestResponse model
- Remove redundant refreshDiagnostics() alias
- Replace catalog loading text with CircularProgressIndicator
- Remove unused string resources (action_restarting, diagnostics, catalog_load)
* fix(#444): remove remaining showDiagnostics reference at line 348
---------
Co-authored-by: Hermes Agent <hermes@agent.local>
* feat(#434): add system tab model files
* feat(#434): add system API endpoints
* feat(#434): rewrite SystemViewModel with full parity
* feat(#434): add system screen UI and string resources
* feat(#434): update tests for new SystemStatsResponse & ViewModel
* fix(#434): fix compile errors — nullable Boolean, SmallFilledTonalButton -> FilledTonalButton
* fix(#434): fix old SystemStatsResponse refs in ChatViewModel, wrap sections in item{} blocks
* fix(#434): rename sections to lowercase, fix LazyListScope extensions
* fix(#434): wrap hook dialog in item{}, pass spacing param to actionLogSection
* fix(#434): update HermesApiServiceTest for new typed SystemStatsResponse
* fix(#434): fix E2E test coroutine timing, remove outdated Gson edge-case test
* fix(#434): fix JUnit 4 assertNotNull argument order
* fix(#434): simplify HermesApiServiceTest, restore all mocks in E2e test
* fix(#434): remove Gson-edge-case test, fix E2E test dispatcher wiring
- Removed testGetSystemStats_nonNumericPercent_returnsNull: tests Gson
type-coercion behavior, not app logic — throws JsonSyntaxException
- Fixed E2eIntegrationTest dispatcher: mock Dispatchers.IO with answers {}
that returns the current Dispatchers.Main dynamically, so runTest's
per-test dispatcher override is honored by both viewModelScope.launch
and async(Dispatchers.IO) blocks → advanceUntilIdle() advances all work
- Removed Dispatchers.Main static mock so runTest can override it
* fix(#434): fix E2E test dispatcher for SystemViewModel
- Removed Dispatchers.setMain + testDispatcher field from setUp
- runTest handles Dispatchers.Main automatically — don't override it
- Mock Dispatchers.IO with answers { Dispatchers.Main } so it
dynamically resolves to whatever Main is at call time (runTest's
TestScope dispatcher)
- This ensures viewModelScope.launch and async(Dispatchers.IO) both
run on the same TestScope dispatcher, making advanceUntilIdle()
effective for all work
* fix(#434): use runTest(testDispatcher) for SystemViewModel test
The SystemViewModel.loadAll() uses async(Dispatchers.IO) inside
coroutineScope { }. With the mocked Dispatchers setup, both Main and
IO return testDispatcher, but runTest()'s default TestScope uses a
different dispatcher instance whose advanceUntilIdle() can't advance
testDispatcher's scheduler.
Fix: pass testDispatcher to runTest() so the TestScope's scheduler
matches testDispatcher's scheduler, making advanceUntilIdle()
effective for all async work.
* fix(#434): align test assertion with toast message change
The SystemViewModel.triggerBackup() toast message was changed from
'Backup triggered successfully' to 'Backup triggered', but the
E2eIntegrationTest assertion wasn't updated, causing a
ComparisonFailure on the unit-tests CI job.
* fix(#434): address review issues
- Fix#1: Change triggerBackup() return type from Unit to ActionResponse
so backupArchive is captured, enabling the download button
- Fix#2: Replace deprecated LocalClipboardManager with LocalClipboard
suspend API (removes CI deprecation warning)
- Fix#3: Consolidate redundant 'Hermes version' InfoRow in gateway
section (sessions + PID shown in one row)
- Remove orphaned system_gateway_state_pid string resource
- Fix import ordering for ktlint compliance
* fix(#434): revert LocalClipboard change, use @Suppress('DEPRECATION') instead
LocalClipboard.setText is not available in the Compose version used
by the project. Reverting to LocalClipboardManager with a suppress
annotation to keep the deprecation warning clean without breaking
compilation.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes@agent.local>
* feat(#445): full Models tab — settings panel, aux tasks, MOA config, model assignment
Main features added:
- Model Settings card at top of screen with main model, aux tasks, MOA summary
- Main model assignment via /api/model/set with expensive model confirm flow
- Auxiliary tasks dialog with per-task model override and reset-all
- MOA config dialog with preset management and reference/aggregator model picker
- Model picker dialog reused for main model, aux tasks, and MOA slot selection
- Kept existing provider browse + pin/unpin/search
New data models: AuxiliaryModelsResponse, MoaConfigResponse,
ModelAssignmentRequest/Response
New API endpoints: GET /api/model/auxiliary, GET/PUT /api/model/moa,
POST /api/model/set
* test(#445): add aux + MOA mock endpoints to E2eIntegrationTest
testModelOptionsSelection_success was failing because the ViewModel's init
block now calls getAuxiliaryModels() and getMoaConfig() which weren't mocked.
Added proper mock returns + imports for the new types.
* fix(#445): correct mock endpoint name in E2eIntegrationTest
- getMoaConfig -> getMoaModels (matches API service method name)
- Add explicit type parameter Response.success<MoaConfigResponse>
to fix Kotlin type inference
* fix(#445): add missing main param to AuxiliaryModelsResponse mock
AuxiliaryModelsResponse requires a MainModelAssignment for its
'main' field. Added the parameter with a dummy value and the
corresponding import.
* fix(#445): update toast assertion in test to match new message format
ViewModel now produces 'Successfully set profile model to ronia'
instead of the old 'Successfully set model to ronia'.
* fix(#445): remove top padding from Models tab LazyColumn
Top inset already provided by HermesScaffold; double padding was chunky next to TopAppBar.
* fix(#445): remove double-stacked top padding on Models tab LazyColumn
HermesScaffold's Box already applies padding(paddingValues) to
offset content below the TopAppBar. The LazyColumn was re-applying
it on the modifier, doubling the top gap. Keep the modifier clean
(.fillMaxSize() only) — the Box handles the offset.
* refactor(#445): inline loadModelOptions into loadAll
loadAll() was just delegating to loadModelOptions() with no extra
logic. Consolidate into a single method to remove the redundancy.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* docs: document HermesScaffold padding foot-gun root cause in AGENTS.md
* feat(#446): plugins tab full parity — install from URL, provider selection, remove confirm, badges
- Add Install from URL section with force reinstall + enable after install toggles
- Add Plugin Provider selection section (memory provider + context engine dropdowns)
- Add rescan button in top bar actions
- Add confirmation dialog before removing plugins
- Show source badges, runtime status, auth badges on plugin cards
- Show dashboard slots from plugin manifest
- Add visibility toggle (show/hide in sidebar) for manifest plugins
- Show orphan dashboard plugins section
- Add PluginManifestData, PluginsHubProviders, ProviderOption models
- Add rescanPlugins, savePluginProviders, setPluginVisibility API endpoints
* fix(#446): fix CI compilation errors — DashboardPluginMeta mapping and @Composable context
* fix(#446): pass missing PluginInfo ctor params for orphan plugin mapping
* fix(#446): remove duplicate refresh button in plugins top bar
Scaffold's onRefresh param added a second refresh icon alongside the
rescan action button. Removed onRefresh/isRefreshing from HermesScaffold
and moved pull-to-refresh into a manual PullToRefreshBox wrapping the
content, keeping the rescan icon as the sole top-bar action.
* fix(#446): remove rescan icon from plugins top bar
Only one action icon left in top bar; pull-to-refresh (loadPlugins)
remains the sole refresh mechanism. rescanPlugins() viewmodel logic
is unchanged but now unreachable from the UI.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#445): full Models tab — settings panel, aux tasks, MOA config, model assignment
Main features added:
- Model Settings card at top of screen with main model, aux tasks, MOA summary
- Main model assignment via /api/model/set with expensive model confirm flow
- Auxiliary tasks dialog with per-task model override and reset-all
- MOA config dialog with preset management and reference/aggregator model picker
- Model picker dialog reused for main model, aux tasks, and MOA slot selection
- Kept existing provider browse + pin/unpin/search
New data models: AuxiliaryModelsResponse, MoaConfigResponse,
ModelAssignmentRequest/Response
New API endpoints: GET /api/model/auxiliary, GET/PUT /api/model/moa,
POST /api/model/set
* test(#445): add aux + MOA mock endpoints to E2eIntegrationTest
testModelOptionsSelection_success was failing because the ViewModel's init
block now calls getAuxiliaryModels() and getMoaConfig() which weren't mocked.
Added proper mock returns + imports for the new types.
* fix(#445): correct mock endpoint name in E2eIntegrationTest
- getMoaConfig -> getMoaModels (matches API service method name)
- Add explicit type parameter Response.success<MoaConfigResponse>
to fix Kotlin type inference
* fix(#445): add missing main param to AuxiliaryModelsResponse mock
AuxiliaryModelsResponse requires a MainModelAssignment for its
'main' field. Added the parameter with a dummy value and the
corresponding import.
* fix(#445): update toast assertion in test to match new message format
ViewModel now produces 'Successfully set profile model to ronia'
instead of the old 'Successfully set model to ronia'.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#433): achievements 1:1 parity with dashboard
- Expanded model: AchievementsResponse now carries header stats
(unlockedCount, discoveredCount, secretCount, totalCount, isStale,
scanMeta/ScanStatus) and per-achievement fields (unlockedAt, secret, criteria)
- Added API endpoints: scan-status, rescan, reset-state, recent-unlocks
- Created AchievementIcon emoji mapper for all Lucide icon names
- Rewrote ViewModel with full state: scan tracking, recent unlocks,
category/state filtering, rescan/reset actions with loading states
- Rewrote Screen: header stats row, scan status chip, stale indicator,
category + state filter chips, recent unlocks section, expandable
criteria, icon emoji, unlocked glow border, overflow menu with
rescan/reset, reset confirmation dialog
- Added 30 string resources for all new UI elements
* fix: resolve CI compilation errors in achievements module
- Replace progress_pct references with correct Kotlin field progressPct (Achievement model uses @SerializedName for snake_case, Kotlin field is camelCase)
- Add missing onStart/onError parameters to safeLaunchLoad calls in loadScanStatus() and loadRecentUnlocks()
* fix: remove top padding on achievements tab content
* fix: remove redundant paddingValues on achievement LazyColumn
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat: add Activity feed showing cron jobs that produced output
- New ActivityItem data model for unified notification feed
- SessionInfo extended with preview/started_at fields
- GET /api/sessions?source=cron endpoint added with source param
- ActivityViewModel fetches cron run sessions, filters for output
- ActivityScreen shows chronological feed with status icons
- Wired into navigation (AUTOMATE drawer section)
- Only shows runs with message_count > 0 (signal-driven)
Closes#402
* fix: add @Composable to statusColor helper (MaterialTheme access)
* fix: Double type for started_at (backend returns float timestamps)
* fix: use GET /api/cron/jobs for Activity feed (no-agent jobs don't create sessions)
Root cause: no-agent cron jobs (jellyfin, battery monitor, etc.) run
scripts directly and NEVER create DB sessions. The previous approach
using GET /api/sessions?source=cron only showed 3 old agent-driven
sessions.
Fix: use GET /api/cron/jobs which tracks last_run_at + last_status for
ALL job types. Filter last_status == "ok" (job ran and delivered).
* fix: match codebase patterns for ViewModel, Scaffold, and NavIcon
- safeApiCall returns NetworkResult, use when(is) not onSuccess/onFailure
- HermesScaffold navigationIcon expects NavIcon.Menu/Back sealed class
- Screen composable param must be onOpenDrawer (capital O)
* fix: correct ActivityScreen API mismatches
- NavIcon.Menu param is onOpen, not onClick
- EmptyState param is subtitle, not description
- Add missing viewModel import
* fix: padding, clickable cards, and navigation on Activity screen
- Fix double padding by using listContentPadding + listItemSpacing
matching the codebase pattern
- Add clickable cards that navigate to CronJobsScreen
- Remove unused scroll and spacer workarounds
* refactor: replace separate Activity feed with delivered-only filter in Cron tab
- Remove ActivityScreen, ActivityViewModel, ActivityItem
- Remove ActivityScreen from navigation keys and registry
- Add showDeliveredOnly toggle to CronJobsUiState
- Add TaskAlt icon button in cron top bar toggles filter
- Jobs with lastRunStatus != "ok" hidden when filter is on
* feat: clickable cron job cards show run-details dialog
- Clicking a job card opens an AlertDialog with last run info
- Shows status, last run time, schedule, error (if any), script
- RunDetailRow helper composable for label/value pairs
* fix: add missing CronJob import in CronJobsScreen
* fix: use snake_case field names for CronJob model
- lastRunAt -> last_run_at, lastError -> last_error
- These are data class constructor params, not computed properties
* refactor: remove delivered-only filter toggle, keep card-click dialog
- Remove TaskAlt filter icon button from Cron tab top bar
- Remove showDeliveredOnly from state and toggleDeliveredFilter() method
- Simplify cron list back to direct state.jobs with no filter layer
* chore: remove dead activity_empty strings
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Implements a full-screen cron job editor dialog following the skills editor pattern:
- Create/edit/delete cron jobs with all backend fields
- CronJobRepeat data class matching backend object shape
- Fixed Gson crash on cron tab load
* feat(#195): add chat attachment support for files and images
- Add Attachment data model with computed properties (isImage, formattedSize, fileExtension)
- Extend ChatMessage with optional attachments list
- Add Coil 2.7.0 for async image loading in Compose
- Extend ChatViewModel with pending attachment state, addAttachment/removeAttachment/clearAttachments
- Build attachment-prefixed message text (data URIs for images, file refs for others)
- Add paperclip button and file picker launcher (ActivityResultContracts.GetContent) to ChatScreen
- Add animated attachment preview chips with thumbnails before sending
- Render attachments inline in UserBubble (AsyncImage for images, file cards for others)
- Add string resources for attachment UI features
* fix(#195): rewrite attachment upload protocol to match Hermes backend RPC methods
BREAKS: removes old buildAttachmentText() that embedded base64 data URIs in prompt text.
Changes:
- Replace inline data-URI encoding with proper backend RPC flow:
- image.attach_bytes for images (fire-and-forget, auto-queues to session)
- file.attach for non-image files (awaited, returns @file: ref text)
- Add CompletableDeferred to PendingRequest so sendMessage can await RPC responses
- Add readContentUriBytes() to read file content from content:// URIs
- Add sendRpcAndAwait() suspend function for sequential RPC calls
- Add RpcCallException for awaited RPC error handling
- Suppress error UI for awaited RPCs (caller handles them directly)
- Keep ChatScreen/ChatBubble UI layer unchanged (file picker, previews, inline images)
* feat(#195): add camera capture support via FileProvider + TakePicture contract
- Creates temporary photo files in cacheDir via FileProvider
- Launches system camera with ActivityResultContracts.TakePicture
- Adds camera icon button (📷) next to the paperclip in ChatInputBar
- Captured photos added as image/jpeg attachments via same addAttachment flow
- FileProvider configured with cache-root path in res/xml/file_paths.xml
- Error handling for launch failures and capture failures with snackbar
* refactor(#195): group camera/image/file into single attach dropdown
- Replaces separate 📷 and 📎 buttons with a single 📎 button
- DropdownMenu opens with three options:
- 📷 Camera → TakePicture via FileProvider temp URI
- 🖼️ Image → GetContent("image/*")
- 📄 File → GetContent("*/*")
- Removes onAttachClick, adds onImageTap/onFileTap callbacks
- Input bar stays clean with one attachment affordance
* fix(#195): add missing imports for CI compilation
ChatScreen.kt:
- android.util.Log
- androidx.compose.animation.core.* (infiniteRepeatable, tween, LinearEasing, RepeatMode, rememberInfiniteTransition)
- androidx.compose.foundation.layout.widthIn
- androidx.compose.material.icons.filled.AttachFile
- androidx.compose.material.icons.filled.InsertDriveFile
- androidx.compose.ui.layout.ContentScale
- coil.compose.AsyncImage
ChatBubble.kt:
- androidx.compose.material.icons.filled.InsertDriveFile
* fix(#195): resolve android-lint LocalContextGetResourceValueCall error
Replace context.getString(R.string.chat_camera_error) with a
composable-level cameraErrorMsg captured via stringResource()
to avoid querying resources via LocalContext.current in a callback.
* fix(#195): migrate InsertDriveFile to AutoMirrored, fix chip double-remove
- Replace deprecated Icons.Filled.InsertDriveFile with
Icons.AutoMirrored.Filled.InsertDriveFile in ChatBubble.kt
and ChatScreen.kt
- Remove onClick from AttachmentChip Surface so only the
dedicated close button triggers removal, not accidental taps
on the chip body
---------
Co-authored-by: Hermes Agent <hermes@agent.local>
* feat(#369): rewrite channels tab with full backend integration
- Add state, error, gateway_running, description to model
- Add test endpoint to API service
- Rewrite ViewModel with toggle, restart, test actions
- Rewrite screen with state badges, restart banner, test button
- Fix string resources and E2E test
* fix(#417): add missing import and fix ButtonSize API
- Add MessagingPlatformTestResult import to HermesApiService.kt
- Replace ButtonDefaults.ButtonSize.SMALL with ButtonDefaults.SmallButtonSize (correct M3 API)
* fix(#417): mark platformStateStyle as @Composable, remove Button size param
- @Composable annotation needed for MaterialTheme.colorScheme access
- Button/OutlinedButton size parameter not available in project's M3 version
- ktlint removed unused ButtonDefaults import
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#365): add pin support for models with pinned section at top
* fix(#365): stub Pinned Models methods in E2eIntegrationTest
* fix(#365): use state flow for pin/unpin, add max cap, add ViewModel tests
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(#219): add pagination to sessions history tab
The /api/sessions endpoint was called without pagination params, so the server returned only its default limit of 20 sessions.
- SessionListResponse: added total/limit/offset fields from server response
- HermesApiService: added @Query params (limit, offset, order) to getSessions()
- SessionsViewModel: added loadMore() that appends pages, hasMore derived from total
- SessionsScreen: added 'Load more sessions' button + loading spinner at bottom of list
* chore: remove plan file from git
* fix: compilation errors in sessions pagination
* chore: gitignore plan files
* fix: remove duplicate brace
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
GET /api/messaging/platforms returns a JSON object with a platforms
array inside, but the app expected a bare array. Wraps the response
in a new MessagingPlatformResponse model.
- Adds MessagingPlatformResponse.kt wrapper model
- Updates HermesApiService.kt return type + adds profile query param
- Updates ChannelsViewModel.kt to unwrap .platforms
- Updates E2eIntegrationTest.kt mock to match new response shape