00c0ddfb92e3d4d53eb2336c464668796914e3cb
119
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
00c0ddfb92 |
feat(billing): adopt subscription.* + usage.bars WS methods (issue #628) (#629)
* feat(billing): adopt subscription.* + usage.bars WS methods (issue #628) Add Billing/Plan screen + WS client surface for the backend release audit (hermes-agent 0bf44d557..614dc194e). credits.view was removed upstream; subscription.{state,preview,change,resume,upgrade} + usage.bars replace it. - WsMethods: add SUBSCRIPTION_* + USAGE_BARS constants - BillingModels: @Serializable request/response (snake_case, ignoreUnknownKeys) - BillingRepository: WS dispatch via HermesWsClient.request + typed decode; HermesRpcException (-32601) surfaces as featureUnavailable, not a crash - BillingViewModel: plan/usage load + upgrade/change/resume/cancel actions - BillingScreen: HermesScaffold composable (plan card, usage bars, actions) - NavigationKeys + ScreenRegistry + strings (en/ko) wire BillingScreen into the INSPECT drawer section Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL. * fix(billing): align models + UI to live gateway wire shape (issue #628) Live-probed the 6 subscription.* / usage.bars RPCs against the running backend (hermes-agent dashboard :9119, 2026-07-19). Findings: - subscription.state returns logged_in / current / tiers / usage (nested), NOT the legacy subscription_type_id / renews_at fields the models assumed. - Mutating RPCs (preview/change/resume/upgrade) return ok:false *results* with error/message/payload (e.g. invalid_token when not portal-authed), not RPC-level errors — so the ViewModel's HermesRpcException guard never fired and the screen silently half-decoded null fields. - subscription.upgrade also returns an idempotency_key not in the model. Changes: - BillingModels: re-shape SubscriptionStateResponse around current/tiers/ usage; add ok/error/message/payload envelope + idempotency_key to the action responses; add SubscriptionCurrent / SubscriptionTier / preview payload types. All nullable + ignoreUnknownKeys (graceful degrade). - BillingViewModel: detect ok:false results on every call and route to featureUnavailable / errorMessage instead of swallowing them. - BillingScreen: read plan from subscription.current; render a clean 'no active plan' / 'log in to Nous Portal' card on the logged-out path instead of a broken null plan card. - strings: add billing_no_active_plan / billing_login_required (en + ko). Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL. * fix(billing): address independent review (agy) — error routing + icon Triage of the independent AGY review on PR #629: Fixed (real issues): - BillingViewModel: usage.bars RPC failure (-32601) no longer marks the whole feature unavailable (was blocking the plan card despite the 'non-fatal' comment); it is now correctly non-fatal. - BillingViewModel: a failed primary subscription load now surfaces errorMessage even when usage bars loaded (was swallowed by 'sub == null && bars == null'). - BillingScreen: render state.errorMessage as an error card inside BillingContent so failed mutating actions (upgrade/cancel) are visible instead of silently disappearing. - BillingScreen: wrap the dead AccountBalanceWallet expression in a real Icon() composable in FeatureUnavailableState. Declined (with rationale, recorded for maintainer): - Tier-select / upgrade UI (review issue #4): explicitly out of scope per the PR body — 'Plan-selection UI ... is not built ... left for a follow-up once backend returns the available plans.' - Rename snake_case Kotlin props to camelCase (review nit #2): incorrect — OkHttpProvider uses JsonNamingStrategy.SnakeCase, which maps snake_case wire keys onto snake_case properties; the props are correct. Fixed the misleading doc comment instead. * fix(billing): match REAL logged-in wire shapes + kill null-result crash The live gateway probe (logged-in Nous Portal session, 2026-07-19) showed the earlier models assumed the WRONG shape: - subscription.state.current is {tier_id, tier_name, monthly_credits, credits_remaining, cycle_ends_at, pending_downgrade_*} — NOT the legacy subscription_type_id/status/renews_at fields. Screen rendered 'Free / no plan' for a real logged-in user. - usage.bars is {available, status, plan_name, plan_bar{remaining_display, total_display, pct_used, fill_fraction}, topup_bar} — NOT a List<UsageBar>. - Mutating RPCs return error:'insufficient_scope' when the Portal token lacks billing:manage scope (ok:false result, not an RPC error). Crash fix (root cause of 'Billing RPC returned no result payload'): - BillingRepository.decode() now returns null on a missing/null result instead of requireNotNull() -> IllegalStateException. All repo methods return nullable; ViewModel handles null as a non-fatal 'No billing data returned' instead of crashing. - BillingScreen.PlanCard / UsageBarRow rewritten to the real fields; the resume/cancel branch now gates on can_change_plan (no status field exists on current anymore). - UsageBarsResponse + UsageBar reshaped to plan_bar/topup_bar; added billing_credits_remaining / billing_plan / billing_topup strings (en+ko). Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL. Decode sim against the captured live responses confirms PlanCard/UsageBarRow/error paths all resolve with no null-crash. * fix(billing): decode WS result that arrives as toAny() Map, not JsonElement Root cause of 'No billing data returned' on a logged-in server: the backend returns a valid subscription.state result, but HermesWsClient.request() yields result already converted via JsonElement.toAny() (a Map<String,Any?>), not a raw JsonElement. BillingRepository.decode() only accepted JsonElement, so was null and the repo returned null -> ViewModel showed the generic 'No billing data returned' message. The server DOES have billing (logged_in:true, tier_name:Free, /usr/bin/bash.10 credits) — the app just couldn't parse it. Fix: decode() now normalizes whatever arrives — a kotlinx JsonElement OR the toAny() Map/List — back into a JsonElement via a recursive Any->JsonElement walk, then deserializes. Both shapes decode correctly now. This also means the previous 'Billing RPC returned no result payload' crash and the generic 'no data' message were both symptoms of this same parse gap, not a missing backend feature. Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com> |
||
|
|
228431d57a |
[enhancement] feat(#606): support auth mode + bearer token when adding HTTP MCP servers (#607)
* feat(#606): support auth mode + bearer token when adding HTTP MCP servers - Extend AddMcpServerRequest with auth (none|header|oauth) and bearerToken - Add auth mode FilterChip selector in HTTP add-server dialog - Conditionally show masked bearer token field when auth == header - Wire new fields through McpServersViewModel request builder Backend contract: exactly one of url/command required; bearer_token requires auth == "header"; args/env only for stdio. SnakeCase naming strategy auto-serializes bearerToken -> bearer_token. Fixes #606 * fix(#606): address agy review — remember PasswordVisualTransformation + FlowRow for auth chips - Wrap PasswordVisualTransformation in remember {} to avoid per-recomposition allocation - Use FlowRow for the 3 auth-mode FilterChips so they wrap on narrow screens instead of overflowing |
||
|
|
b3765bd106 |
feat: in-app display language switcher (Settings → Appearance) (#604)
Add a Language selector to Settings → Appearance that overrides the app display language independent of the device locale. Persists a BCP-47 code in ServerStoreState.appLanguage; MainActivity.attachBaseContext applies it via a ContextWrapper (works on minSdk 26, no AppCompat dependency needed since the Activity is a plain ComponentActivity). Selecting a language recreates the activity so resources re-resolve immediately. Includes System default / English / 한국어 options, plus a unit test for the locale-code resolver. Note: the new string keys (settings_item_language, language_system, language_english, language_korean) should be backfilled into values-ko once PR #602 lands. Co-authored-by: hy4ri <hy4ri@users.noreply.github.com> |
||
|
|
d9f20f8eb2 |
fix(#576): surface command.dispatch 4018 errors instead of silent dead-tap (#603)
* fix(#576): surface command.dispatch 4018 errors instead of silent dead-tap * fix(#576): fall back to slash.exec for command.dispatch 4018 registry misses * fix(#576): block cli_only/TUI-only commands at dispatch (blocklist deliverable) * review(#603): address agy findings — tighten 4018 match, case-insensitive filter, add double-fault + blank-output tests * fix(#576): surface command dispatch outputs and update tests --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
d1fc477bcd |
fix: resolve all build compilation and style check warnings (#597)
* fix: resolve all build compilation and style check warnings * fix: remove unused import in SystemViewModel |
||
|
|
48f5ffbe52 |
fix(#589): show in-session model picker when user sends /model (#596)
* fix(#589): show in-session model picker when user sends /model Typing a bare '/model' in the chat composer now opens the model picker so the user can hot-swap the CURRENT session's model via the existing slash-command path, instead of hand-typing the provider/model string. - ChatViewModel.preloadModelOptions() caches GET /api/model/options at GatewayReady so the picker opens instantly; falls back to on-demand refresh if opened before preload finishes. - sendMessage() intercepts a bare '/model' (no arg) -> showModelPicker=true instead of dispatching; a typed '/model provider/model' still dispatches via handleSlashCommand. - sendSlashModel() routes through command.dispatch with session_id, so the swap is session-scoped (not the global POST /api/model/set). - Extracted ModelPickerDialog into ui/model/components and reused by both ModelScreen and ChatScreen (adds loading + empty states). - Added a top-bar model chip (current session model label) that opens the same picker. - Added unit tests: bare /model opens picker, picker selection hot-swaps via slash dispatch with session_id, typed /model with arg dispatches directly. Note: CI shows 2 pre-existing test failures (ChatViewModelTest.testToggleSearch, HermesWsClientTest.testAutoReconnect) that also fail on a clean main full suite (MockK static-mock leakage between classes). Unrelated to this change; compile + ktlint + the 3 new tests pass. * fix(#589): use backend-valid session-scoped /model command syntax The model picker now dispatches the canonical backend command form '/model <model> --provider <slug> --session' instead of the unsupported 'provider/model' slash form. The '--session' flag keeps the switch scoped to the current chat only (per-session override) and never writes the global model config, which matches the backend model-switch contract. - sendSlashModel builds '/model $model --provider $provider --session'. - Updated unit tests to assert the corrected arg payload ('gpt-4o --provider openai --session') and the fully-typed dispatch path. * fix(#589): add pinned-models section to in-session model picker - ModelPickerDialog now renders a Pinned section (mirrors global model screen) - search filter matches model names too, not just provider name/slug - ChatUiState gains modelPickerPinned, fed from AuthManager.getPinnedModels() - top-bar model chip removed; picker opens on /model only - test mock for getPinnedModels() added * fix(#589): route /model via prompt.submit instead of command.dispatch (resolves 4018) /model is a real backend slash command handled in the backend's message pipeline (_handle_message -> _handle_model_command), so it must arrive as a normal prompt.submit message, not the command.dispatch RPC (which only knows quick/plugin/bundle/skill commands and 4018s on /model). - SlashCommandDispatcher: add SlashResult.ModelSwitch, map /model -> ModelSwitch - ChatViewModel: handle ModelSwitch -> handleModelSwitch() sends via WsMethods.PROMPT_SUBMIT with {session_id, text} - Tests: rewrite the two /model tests to assert prompt.submit (previously asserted the buggy command.dispatch path); add /model -> ModelSwitch test Independent agy review: APPROVE, no blocking issues. * fix(#589): route /model via slash.exec (correct transport; prompt.submit made LLM treat it as text) Earlier attempt routed /model through prompt.submit, but the TUI gateway's prompt.submit handler does NOT parse slash commands — it runs the text as a normal agent turn, so the LLM received '/model ...' as a user message (the exact reported bug). The correct RPC is slash.exec, which runs the command through the slash worker and _mirror_slash_side_effects -> _apply_model_switch to hot-swap the live session model. command.dispatch is also wrong (only knows quick/plugin/bundle/skill commands -> 4018). - WsMethods: add SLASH_EXEC = "slash.exec" - ChatViewModel.handleModelSwitch: send slash.exec with {command, session_id} (previously prompt.submit with {text}); corrected doc comment - SlashCommandDispatcher: corrected comments (slash.exec, not prompt.submit) - ChatViewModelTest: rewrite both /model tests to assert slash.exec with the /model <model> --provider <slug> --session command string - SlashCommandDispatcherTest: /model -> ModelSwitch already covered Verified against hermes-agent tui_gateway/server.py: slash.exec (L13190) -> worker.run + _mirror_slash_side_effects (L13299) -> _apply_model_switch (L13118); parse_model_flags accepts --provider/--session (hermes_cli/model_switch.py:355). Independent agy review of the prior attempt: APPROVE (the wrong-transport assumption was the author's, not agy's). * fix(#589): route /model via config.set key=model (slash.exec 4001'd; prompt.submit made LLM treat it as text) Third transport attempt, now verified against the gateway source: - prompt.submit: gateway runs text as a normal agent turn -> LLM sees '/model' as a message (the original reported bug). WRONG. - slash.exec: gateway returns 4001 'session not found'. The slash worker path resolves the session differently / requires agent-built state the mobile session isn't in at switch time. WRONG. - config.set key=model: gateway (server.py config.set L10253) routes key=='model' straight to _apply_model_switch using the SAME _sessions.get(session_id) lookup that the working command.dispatch uses. CORRECT. sendSlashModel still builds '/model <model> --provider <slug> --session'; parse_model_flags (hermes_cli/model_switch.py:355) extracts model/provider/ session from it. handleModelSwitch now sends config.set {key:model, value: command, session_id}. - WsMethods: add CONFIG_SET = 'config.set' - ChatViewModel.handleModelSwitch: config.set instead of slash.exec - SlashCommandDispatcher: comments updated (config.set, not slash.exec) - ChatViewModelTest: both /model tests assert config.set key=model + command - also fixes the MockK testToggleSearch flake by preloading byte-buddy agent via JAVA_TOOL_OPTIONS locally (container blocks JVM self-attach) * fix(#589): strip /model prefix before config.set key=model (5001 'model names can't have spaces') config.set key=model passes the value DIRECTLY to parse_model_flags — it does NOT strip a leading '/model' the way slash.exec / prompt.submit do. Sending '/model <model> ...' made parse_model_flags treat '/model <model>' as the model name (contains a space) -> 5001. handleModelSwitch now strips the '/model' prefix and sends the bare spec '<model> --provider <slug> --session', matching the TUI client's modelValueForConfigSet (ui-tui session.ts:23) used by useMainApp.ts:123. - ChatViewModel.handleModelSwitch: spec = command.removePrefix('/model').trim() - ChatViewModelTest: assert bare spec 'gpt-4o --provider openai --session' - comment updated * fix(#589): strip /model prefix case-insensitively before config.set SlashCommandDispatcher and isModelPickerCommand match /model case-insensitively, but handleModelSwitch used removePrefix("/model") (case-sensitive). A typed /MODEL ... forwarded the literal slash prefix to config.set and the backend's parse_model_flags wouldn't recognize it — silent hot-swap failure. Use startsWith("/model", ignoreCase=true) + substring(6) so any casing is stripped. Added a regression test asserting /MODEL does not forward a leading slash. * fix(test): inject searchDispatcher to fix MockKException in testToggleSearch --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
c543eb6470 |
feat(#530): server-backed session search (FTS5) (#555)
* feat(#530): add SessionSearch models + searchSessions endpoint + contract tests * feat(#530): wire server-backed search into Sessions screen (debounce + render) * style(#530): ktlint import ordering on HermesApiService * fix(#530): ktlint-clean overlap wrap + map search snippet to title * fix(#530): map search snippet to title so results show + highlight * fix(#530): render search hits as honest result cards instead of faking title Backend SessionSearchResult carries no session name (only snippet + metadata), so mapping snippet->title made the card show raw excerpt as the title. Instead, render a dedicated SearchResultCard: 'Match' label + session id, highlighted snippet as body, and source/model/played-at chips. Added SessionInfo.model and a friendly relative-time formatter. ktlint clean, compileDebugKotlin + unit tests pass. * fix(#530): strip JSON from search snippet + tighten result card padding * fix(#530): strip FTS highlight markers from search snippet on client agy's backend change re-wraps matched terms in >>>term<<< markers inside the clean snippet, but the mobile highlights by raw query term and never consumed those markers — so literal '>>>'/'<<<' would render in result text. Strip the markers before passing the snippet to highlightText so the query highlight still works on clean text. * fix(#530): integrate tree-flattening hierarchy with search modes in SessionsScreen * fix(#530): fix test assertion and mock ApiClient in SessionsViewModelTest * fix(#530): resolve compose PaddingValues compile error and fix slash-preservation test path * fix(#530): clean search snippet from JSON payload and add unit test --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
907d247312 |
fix: refresh loopback dashboard token after restart (#568)
* fix: refresh loopback dashboard token after restart The gateway regenerates its ephemeral session token on every restart, so the token the app persisted becomes stale. After a dashboard restart REST calls 401 and the WebSocket auto-reconnect loop hangs in AUTH_EXPIRED until the user manually re-authenticates. - Add DashboardSessionTokenRefresher: re-reads the injected window.__HERMES_SESSION_TOKEN__ from the dashboard SPA and writes it back to AuthManager. - TokenRefreshAuthenticator: on a 401 retry, scrape a fresh token from the dashboard and retry instead of giving up. - HermesWsClient: refresh the token before each non-gated WebSocket handshake so reconnects don't get stuck on a stale token; guard serverStore read against IllegalStateException. Covers the loopback (--insecure) deployment path; gated/ticket mode is untouched. * fix: harden dashboard token refresher (sync, body cap, blank-token test) Second-opinion review flagged robustness gaps (no correctness/security regressions in the happy path): - DashboardSessionTokenRefresher.refresh(): @Synchronized to dedupe concurrent 401 / reconnect storms instead of each firing its own GET. - Cap SPA HTML read at 64 KiB before regex scan (defensive against OOM on low-end devices); we only need the injected token near the document top. - HermesWsClient: log (not silently swallow) the transient uninitialized serverStore case so a real misconfig is still visible. - Add test: blank-injected token returns null. --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
ee28ee5ce2 |
feat(#538): wire tool.progress/generating + subagent.* events into chat (#567)
* feat(#538): wire tool.progress/generating + subagent.* events into chat * fix(#538): remove subagent indicator on subagent.complete so it doesn't linger * style(#538): drop dead Elvis on non-nullable event.text in onMessageComplete --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
9e10c813d4 |
feat(#534): add OAuth provider management screen + credential_warning surfacing (#566)
* feat(#534): add OAuth provider management screen + credential_warning surfacing * style(#534): fix ktlint import-ordering in ProvidersViewModel * fix(#534): address agy review — terminal DONE/ERROR dialog states + PKCE browser launch * fix(#534): add Done/Close dialog strings * fix(#534): address agy review - terminal DONE/ERROR dialog states, real PKCE browser launch, clear flow state --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
ad87cd1054 |
feat(#533): make /fork work via chat (session.branch) (#565)
* feat(#533): add session.branch (fork a conversation) to mobile The gateway already supports session.branch (tui_gateway/server.py), but the mobile had no client surface. This adds: - WsMethods.SESSION_BRANCH = "session.branch" - A Branch (CallSplit) action on each session card that forks it via the session.branch WS RPC with the source session_id (and optional name). - branchedSessionId state -> SessionsScreen navigates to the freshly forked session on success; RpcError -> toast; spinner while branching. - SessionsViewModelTest (4 tests): forwards session_id, forwards name, sets branchedSessionId on success, toast+no-branch on RpcError, and consumeBranchedSession clears the pending id. Verified against the live gateway contract: session.branch expects {session_id, name?} and returns {session_id, title, parent}. Local testDebugUnitTest: 4/4 pass, ktlint clean. * refactor(#533): make /fork work via chat instead of a session-menu button Per review feedback: the session.branch surface belongs in the chat composer, not the session list menu. - SlashCommandDispatcher: /fork and /branch -> new SlashResult.SessionBranch. - ChatViewModel: handles SessionBranch by sending session.branch with the active runtime session id (optional arg = branch title), and on the RPC result switches the current session to the new forked one (mirrors the SESSION_CREATE handler). This fixes /fork, which previously fell through to command.dispatch and 4018'd (backend only knows session.branch). - Removed the Branch (CallSplit) button + navigation LaunchedEffect from SessionsScreen/SessionsViewModel (reverted those two files to main) and dropped the now-unused sessions_action_branch string. - Tests: deleted SessionsViewModelTest (branch-only); added /fork -> SessionBranch to SlashCommandDispatcherTest and two ChatViewModel tests asserting /fork sends session.branch with the session id + optional name. Local testDebugUnitTest: SlashCommandDispatcherTest 8/8, ChatViewModelTest 50/50, 0 failures. ktlint clean. --------- Co-authored-by: ronia <ronia@m57> |
||
|
|
168d27dcca |
feat(#552): surface tool.output_risk WS event as security chip (#561)
Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
3052db7d65 |
feat: paginate, sync, and display branched sessions (#563)
* mobile: 以 Desktop Sessions 作为聊天历史源 * mobile: 修复快速连接崩溃与无标题会话隐藏 * mobile: 对齐 Desktop 标题并恢复完整历史 * mobile: 分离 Desktop 存储 ID 与运行 Session ID * mobile: 将新对话持久化为 Desktop Session * mobile: 渲染 Desktop 本地媒体 * feat: 分页加载并同步当前会话 * fix: 显示分支会话名称与层级 * fix: align PR with upstream app contract * fix: preserve upstream session RPC lifecycle |
||
|
|
306861a222 | feat(#553): animate reaction WS event (ily / <3 / good bot → hearts) | ||
|
|
9156064d2b |
feat(#537): analytics screen — usage + per-model stats (#558)
* feat(#537): analytics screen — usage + per-model stats from /api/analytics/* * fix(#537): address PR review — cancel-in-flight loads, retain cache on refresh, drop redundant async * fix(#537): surface real analytics failure, mirror backend shape (period_days/tools), round-trip test * fix(#537): tolerate float last_used_at in analytics (backend emits epoch as number) * fix(#537): type tools[] as AnalyticsToolUsage (tool/count/percentage), render Top tools section * fix(analytics): fix crashes and refactor tab layout * perf(analytics): cache responses and prefetch day windows --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
afa82ceec2 |
feat(#532): add process.list + process.kill manager screen (#556)
Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
cf893585b1 |
feat(#527): surface gateway error + background.complete WebSocket events (#550)
Mobile was silently dropping two event types the gateway emits. This adds parsing + UI surfacing for both, bringing mobile to parity with desktop (which shows gateway errors as red toasts). - `error` (backend crash) -> WsEvent.GatewayError, surfaced in the existing error banner (mirrors how RpcError is handled). - `background.complete` (scheduled job finished) -> WsEvent.BackgroundComplete, surfaced as a non-blocking snackbar in ChatScreen. Changes: - WsEvent.kt: add GatewayError(message?) + BackgroundComplete(data?) sealed subtypes. - EventParser.kt: handle "error" + "background.complete"; unknowns still fall through to Unknown (no behavior change for existing event types). - ChatWsEventReducer.kt: onGatewayError (sets errorMessage, clears loading) + onBackgroundComplete (sets backgroundCompleteMessage); mirrors onRpcError. - ChatViewModel.kt: backgroundCompleteMessage state field, clearBackgroundComplete(), handleWsEvent branches (ViewModel owns the snackbar trigger). - ChatScreen.kt: pass backgroundCompleteMessage to ChatLifecycleEffects + a LaunchedEffect that shows the snackbar and calls clearBackgroundComplete(). - EventParserTest.kt: cover both new events (incl. missing-message case). Pure additive; sealed class + exhaustive when() preserved. Verified: compileDebugKotlin + ktlintCheck green; EventParserTest passes. Closes #527 Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
480d29e481 |
fix(#545): fix gated ws auth loop and ticket refresh (#546)
Fix gated close codes, run ticket refresh on Dispatchers.IO, prevent scope race on startup, and auto-format files. |
||
|
|
53b3966931 |
feat(#525): render reasoning.delta / reasoning.available stream (#543)
* feat(#525): add ReasoningDelta + ReasoningAvailable WsEvent types * feat(#525): parse reasoning.delta + reasoning.available; route through reducer + StreamingState * feat(#525): add reasoning streaming buffer to ChatStreamingController * feat(#525): dispatch ReasoningDelta/ReasoningAvailable in ChatViewModel * feat(#525): render collapsible ReasoningIndicator in chat list * fix(#525): address review — icons+semantics, LocalHermesStatusColors, drop redundant ReasoningAvailable branch * feat(#525): persist reasoningText on model, entity, mapper + Room migration 3->4 * feat(#525): reducer + streaming carry reasoningText onto message * feat(#525): render persisted reasoning above each assistant bubble, drop global indicator * nix: flake * nix: flake --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
def32f8f27 |
fix(ws): align RPC timeout to 120s + rejectAllPending on disconnect (fixes #526) (#541)
* fix(ws): align RPC timeout to 120s + rejectAllPending on disconnect (fixes #526) Mobile's pending-request handling diverged from the desktop contract (apps/shared JsonRpcGatewayClient) in two ways that caused silent hangs: 1. Timeout was 30s while the desktop uses 120s (DEFAULT_REQUEST_TIMEOUT_MS), so legitimately long agent turns were pruned early. Align to 120s and use it for both the cleanup sweep and sendRpcAndAwait's per-call withTimeout. 2. The cleanup sweep only Log.w'd stale requests and dropped them — it never failed the CompletableDeferred, so sendRpcAndAwait callers awaiting a server that never answered HUNG FOREVER. And on disconnect/reconnect there was NO rejectAllPending, so in-flight awaited RPCs also hung until their own timeout. Changes (all in ChatViewModel.kt): - cleanup sweep now completeExceptionally()s the deferred AND surfaces a 'Request timed out' error for fire-and-forget RPCs (was silently dropped) - new rejectAllPending() fails + clears every in-flight pending deferred; wired into the disconnect-status collector and reconnect() - PENDING_REQUEST_TIMEOUT_MS = 120_000 (matches desktop); sendRpcAndAwait uses it instead of a hardcoded 30s Tests: 2 new ChatViewModelTest cases cover rejectAllPending on disconnect and on reconnect (no hang, VM stays usable). (Timeout-surfacing path shares the deferred-fail logic with handleRpcError; the 120s sweep can't run under the unit-test dispatcher's periodic-timer limitation, noted in PR.) Mirrors the desktop-app audit in /opt/hermes-agent. * fix(ws): aggregate pending-request cleanup into a single UI update (PR #541) Sourcery review (PR #541): startPendingRequestCleanup emitted one _uiState.update{} per stale request inside the loop, causing N separate emissions / redundant recompositions when many RPCs time out at once. Collect fire-and-forget methods and emit a single combined errorMessage update after the loop. rejectAllPending is intentionally left silent on disconnect/reconnect: those are transient socket events (the RPC often completes on the new socket), so surfacing a scary error banner is UX noise. Only the timeout path surfaces errors — that is a genuine failure. This matches the desktop JsonRpcGatewayClient.rejectAllPending semantics. * refactor(ws): move awaited-RPC pending/timeout layer into HermesWsClient (closes #526) Issue #526: port the JsonRpcGatewayClient.request() pattern into HermesWsClient. HermesWsClient now owns the in-flight map, per-call 120s timeout, resolve-on-RpcResult/RpcError, and rejectAllPending on disconnect/reconnect. ChatViewModel.sendRpcAndAwait simply delegates to wsClient.request(...).await(); its local pendingRequests map, PendingRequest, startPendingRequestCleanup loop and rejectAllPending are removed. Fire-and-forget send() is unchanged. --------- Co-authored-by: M57 <m57@hermes-control.app> |
||
|
|
c5d1ef1c42 |
fix(network): scope management REST calls with ?profile= (fixes #528) (#540)
* fix(network): scope management REST calls with ?profile= (fixes #528) Mobile fired all management REST calls with NO profile scope, so every profile read/wrote the backend's implicit default profile — multi-profile setups silently corrupted config/skills/toolsets/mcp/model/env. Add ProfileScopeInterceptor (OkHttp) that appends ?profile=<activeProfile> to the same endpoint families the desktop/web dashboard scopes (web/src/lib/api.ts PROFILE_SCOPED_PREFIXES), respecting: - no active profile -> pass through (legacy default behavior) - explicit ?profile= on the URL wins over the global scope - only scoped families rewritten; ops/pairing/cron/profiles stay global Wired into ApiClient.buildService(). 5 MockWebServer tests cover scoped append, explicit-param precedence, no-profile passthrough, non-scoped skip, and skills scoping. Mirrors desktop/web contract derived from audit of /opt/hermes-agent. * fix(ci): stub getSelectedProfileId in ApiClientTest + harden prefix match (fixes PR #540) - ApiClientTest: stub AuthManager.getSelectedProfileId() to null. The new ProfileScopeInterceptor (registered in ApiClient.buildService) calls it, and the unstubbed real method threw IllegalStateException at AuthManager.kt:56, breaking all 4 auth-interceptor tests. - ProfileScopeInterceptor: replace startsWith(prefix) with whole-segment match (path == prefix || path.startsWith(prefix/)) so lookalike paths like /api/statusXYZ are NOT wrongly scoped (Sourcery review, PR #540). - ProfileScopeInterceptorTest: add lookalikePath_notScoped + scopedSubPath_isScoped to lock the segment-boundary fix. |
||
|
|
d056c77c93 |
feat(chat): handle sudo.request / secret.request and respond via RPC (fixes #524) (#539)
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. |
||
|
|
cec8795b53 |
fix(#497): pass include_unconfigured=true to /api/model/options (#498)
Backend commit 37a4cf900 (hermes-agent) flipped the default of GET /api/model/options's include_unconfigured parameter from true to false, so the mobile Models picker was silently regressing to showing only configured/authenticated providers — the skeleton rows for unconfigured providers (with setup-hint warnings) stopped arriving. Add include_unconfigured as a @Query param defaulting to true so the full provider universe is restored on the Models screen. Both current call sites (ModelViewModel + ProfilesViewModel) invoke the API without passing the new param, so they automatically pick up the true default. |
||
|
|
04bb7c4325 |
Fix/issue 470 stuck reconnecting (#495)
* fix(ws): stop chat opening from racing the singleton into a stuck RECONNECTING Root cause: HermesWsClient is a global singleton connected once at login; every tab observes its single connectionStatus. ChatViewModel is recreated on every chat-tab open and its init calls connectWebSocket() -> wsClient.connect(). connect() only guarded the CONNECTED case, so when the socket had dropped (singleton in RECONNECTING with its own scheduled reconnect) opening chat spawned a SECOND socket on the same webSocket field. The orphaned socket's later onClosed set connected=false + re-armed RECONNECTING after the new socket connected, and currentBackoff kept resetting on each chat open, leaving the status stuck on RECONNECTING while every other tab (which never calls connect()) stayed healthy. Fix: - HermesWsClient.connect() now no-ops while CONNECTING/RECONNECTING (in-flight socket already pending) and while AUTH_EXPIRED (needs re-auth, not a loop). - ChatViewModel.connectWebSocket() mirrors the same terminal-state guard so the chat tab never disturbs an already-working or already-recovering connection. Behavior-preserving: happy path (CONNECTED) and genuine terminal states (DISCONNECTED/NO_NETWORK) still connect as before. * fix(#470): sync cookie store scope with selected profile ID Update AuthManager to switch CookieManager to the active server profile ID on startup and on runtime profile change. * fix(#470): prevent stale token cache and load initial cookie scope Pass active profile scope to CookieManager.initialize on startup and update setProfileToken to sync the in-memory cachedToken. * feat(chat): add re-login action and credentials popup to connection banner Add a Re-login button to the connection status banner and a dialog to prompt for credentials, allowing the user to refresh session cookies/tokens and reconnect the WebSocket. * fix(chat): address PR #495 review feedback * fix(chat): restore reconnect action in banner * fix: resolve composable invocation in ReloginDialog * fix(chat): enable re-login action during reconnecting state Add the Re-login button to the connection status banner when connectionStatus is RECONNECTING, allowing users to re-authenticate and resolve stuck reconnect loops. |
||
|
|
d178d7f69e |
refactor: proper OkHttp CookieJar integration (issue #470) (#483)
Replace the manual session-cookie interceptor in ApiClient and the manual cookie branch in TokenRefreshAuthenticator with a shared PersistentCookieJar backed by an encrypted, per-server EncryptedCookieStore. - Add CookieStore iface + EncryptedCookieStore (EncryptedSharedPreferences, per-server scope, legacy single-cookie migration) - Add CookieSerialization (OkHttp Cookie <-> holder) + PersistentCookieJar (in-mem cache, async persist, atomic useStore scope switching, prune, session-cookie accessor) - Add CookieManager coordinator (init/useStore/prune/clear) and wire its cookieJar into OkHttpProvider base/websocket/probe (lazy) - AuthManager.get/setSessionCookie now delegate to CookieManager; add pruneServerCache(); clear path drops the scope's session cookie - AuthLoginViewModel + HermesWsClient rely on the jar for Set-Cookie capture and session-cookie re-attachment (no manual header injection) - Add fakes (FakeEncryptedCookieStore/buildFakePersistentCookieJar) + unit tests (PersistentCookieJarTest, CookieManagerTest); inject fake jar into ApiClientTest/HermesWsClientTest setup WS token-out-of-URL (step 5) deferred: backend hermes-agent authenticates WS only via ws.query_params, so moving it requires a coordinated backend change (noted for follow-up). Verified: testDebugUnitTest (334 passing) + assembleDebug green via local SDK. |
||
|
|
aa59e704a4 |
fix(#478): rework default login to use unified ConnectionProfile model (#482)
* fix(#478): rework default login to use unified ConnectionProfile model - AuthManager: drop legacy auth_token path; getToken/setToken now delegate to the selected profile (profile token only, no global fallback) - setHost/setPort always write the selected profile (no top-level host/port branch) - Add DEFAULT_PROFILE_ID + ensureDefaultProfile()/ensureDefaultSelected() so the selected profile is never null; ensureDefaultSelected() only falls back when nothing is selected (never clobbers an existing choice); ensureDefaultProfile() only injects a Default profile on fresh install, never into an existing list - One-time migration folds legacy standalone auth_token into the Default profile, batched into a single SharedPreferences transaction (guarded by legacy_default_migrated) - ConnectViewModel.connect() persists via setToken/setHost/setPort (delegate to the selected profile) — keeps the existing persistence contract - Connect/Settings: remove the '(none)'/standalone profile option; deleteProfile falls back to Default profile instead of null selection - Update AuthManagerTest/ConnectViewModelTest to the unified model; fix test stub ordering + migration re-init; remove dead connect_profile_none / settings_profile_none strings Closes #478 * fix: stub AuthManager.ensureDefaultSelected() in E2eIntegrationTest The #478 ConnectViewModel.connect() success branch now calls AuthManager.ensureDefaultSelected() on the non-saveProfile path. E2eIntegrationTest.setUp() mocks AuthManager as a non-relaxed mockkObject but never stubs that method, so the unstubbed call throws MockKException inside viewModelScope.launch, leaving connectionSuccess unset and failing 3 tests: - testFullUserSessionFlow - testConnectViewModel_onPairingString_base64Format - testConnectViewModel_onPairingString_urlFormat Add the missing stub. Verified locally: full suite 322/322 passing. --------- Co-authored-by: ronia <ronia@m57> |
||
|
|
b65d0297e0 |
refactor(cleanup): remove 5 orphaned symbols (no behavior change) (#484)
- ChatViewModel: drop fetchAndDisplayStatus/Sessions/Stats — left behind
after slash-command dispatch rework (
|
||
|
|
bdf566ba3e |
feat(#480): pass refresh=true on Models pull-to-refresh to re-probe custom providers (#481)
* feat(#480): pass refresh=true on Models pull-to-refresh to re-probe custom providers * test(#480): assert refresh flag propagation for default vs pull-to-refresh loads * fix(#480): capture refresh arg via slot instead of brittle coVerify in propagation test --------- Co-authored-by: ronia <ronia@m57> |
||
|
|
430a083647 |
feat(#471): implement DataStore-based server configuration and self-h… (#477)
* 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 |
||
|
|
5697405b19 |
refactor: migrate Gson serialization to kotlinx.serialization (#475)
* 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 |
||
|
|
d5fa70b34b |
fix(chat): support session IDs containing slashes in Retrofit endpoints (#473)
* fix(chat): support session IDs containing slashes in Retrofit endpoints
Set encoded = true on the @Path("id") annotations for all session endpoints
to prevent URL-encoding slashes, which caused 404 errors.
* chore: clean up inline comments — drop kanban metadata, align issue ref to #468
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
|
||
|
|
9689d17be0 |
feat(#443): Telegram onboarding, remove platform, credential expiry, field validation (#467)
* feat(#443): Telegram onboarding, remove platform, credential expiry, gateway command, field validation - Add Telegram onboarding wizard: start (QR), poll status, cancel, apply with allowed-user-IDs flow. Full state machine in ChannelsViewModel. - Add remove platform button with confirmation dialog (per card). - Add credential expiry display in env var config fields. - Add gateway_start_command to the offline banner with monospace hint. - Add field validation for Slack token prefixes and member IDs. - Add admin section showing env path and gateway command. - Add DELETE, POST, GET API endpoints for onboarding + remove platform + action status polling. - Extend MessagingPlatform model with envVar expiresAt, homeChannel, updatedAt fields. * fix(#443): address review feedback + fix CI compilation & ktlint - Remove duplicate getActionStatus overload (compilation blocker) - Add missing Box import in ChannelsScreen (compilation blocker) - Remove unused gatewayCommand param from AdminSection - Centralize ISO timestamp parsing with UTC timezone - Clean up WAITING phase dialog (remove redundant confirmButton) - Use LocalHermesStatusColors for semantic surfaces - Fix all ktlint violations in changed files --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com> |
||
|
|
b46e0ef46d |
fix: correct FilterChipRow and EmptyState API mismatches in Skills screen (#466)
* 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> |
||
|
|
e74fe46a5f |
feat(#442): categorized env keys screen with add/remove, search, and gateway restart (#464)
* 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> |
||
|
|
5be4075d95 |
feat(#444): MCP tab parity — add servers, catalog, env vars, restart, diagnostics (#462)
* 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> |
||
|
|
d181851670 |
feat(#441): add profile cloning and description editing (#463)
* feat(#441): add profile cloning and description editing * feat(#441): add profile builder wizard and API enhancements Introduce 5-step Profile Builder wizard into ProfilesScreen and add support for create/clone endpoints. * fix(#441): restore imports and add ProfileInfo constructor defaults * style: run ktlint format on codebase to fix styling errors * fix(#441): address PR review comments including provider slug bug, validation, and strings * fix(#441): remove unused McpServerConfigInput import --------- Co-authored-by: Hermes Agent <hermes@agent.local> |
||
|
|
846a9380e2 |
feat(#434): System tab 1:1 parity with dashboard (#461)
* 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> |
||
|
|
e2349eca22 |
feat(#439): enrich sessions tab with dashboard features (#460)
* feat(#439): enrich sessions tab with dashboard features Add session stats (total/active counts), per-session source icons, active session badges, inline rename, bulk selection with toolbar, prune old sessions dialog with configurable days threshold, session prompt copy, single-session delete, and search highlighting. - SessionInfo model: add source field - New models: SessionStatsResponse, SessionRenameRequest, BulkDeleteRequest, PruneRequest, SessionPromptResponse - HermesApiService: add stats, rename, bulk delete, prune, prompt endpoints - SessionsViewModel: stats loading, bulk select, rename, delete, prune - SessionsScreen: stats row, source icons, active border, rename inline, bulk mode with animated toolbar, prune dialog, search highlight - strings.xml: new resources for all sessions UI features * fix(#439): resolve CI compile errors - Add explicit icon imports for Code, Language, Terminal - Add missing clickable import - Make highlightText non-composable by passing color params - Fix combinedClickable usage on SessionCard * fix(#439): address review findings - Remove unused background import - Add dedicated DELETE /api/sessions/{id} endpoint - Refactor deleteSession to use dedicated endpoint - Add confirmation dialogs for both single-session and bulk delete - Surface statsError in the UI when stats load fails - Add string resources for delete confirmation dialogs * fix(#439): fix delegated property smart cast errors - Capture statsError, errorMessage, sessionToDeleteConfirm in local vals before null checks to avoid 'Smart cast impossible' on delegated props * fix(#439): remove copy and rename buttons, keep delete only * fix(#439): restore CheckCircle import --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com> |
||
|
|
41fde55c11 |
feat(#445): full Models tab parity — settings, aux tasks, MOA config (#458)
* 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> |
||
|
|
57a815c67e |
feat(#446): plugins tab full parity — install from URL, provider selection, remove confirm, badges (#456)
* 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> |
||
|
|
0ee24144c0 |
Revert "feat(#445): full Models tab parity — settings, aux tasks, MOA config"
This reverts commit
|
||
|
|
dc808daee8 |
feat(#445): full Models tab parity — settings, aux tasks, MOA config
* 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> |
||
|
|
efb8e528ef |
feat(#448): schema-driven config UI with category tabs, search, typed inputs
Closes #448 Replaced the raw YAML editor with a full schema-driven config management UI matching the dashboard's ConfigPage experience. ### New API endpoints (HermesApiService) - GET /api/config — load structured config - GET /api/config/schema — load schema with field types + categories - GET /api/config/defaults — load defaults for reset - PUT /api/config — save config (deep-merged on backend) ### Data models - ConfigSchemaResponse — wraps fields + category_order - SchemaField — type, description, category, options - ConfigUpdateRequest — config map + optional profile ### ConfigViewModel - Loads config + schema + defaults + path on init - Tracks pending changes as dot-path → JsonElement map - Builds nested JSON changeset for the save API - YAML mode toggle with raw YAML load/save - Per-category reset to defaults ### ConfigScreen - Category tabs with field-count badges - Search filtering across key, label, description, category - Typed inputs: boolean toggles, select dropdowns, number inputs - Modified highlighting with primaryContainer tint - YAML ↔ Form mode toggle - Sticky save indicator with pending-change count |
||
|
|
d875c8a040 |
feat: richer Webhooks tab — create, toggle, delete subscriptions (#454)
* feat: richer Webhooks tab — create, toggle, delete subscriptions Adds full webhook subscription management matching the dashboard UI: - Create subscription dialog (name, description, events, deliver, secret) - Per-subscription enable/disable toggle via PUT endpoint - Delete with confirmation dialog - Enhanced subscription cards: description, event chips, delivery badge, secret indicator, URL display - Search filtering across name, description, and events - Updated Webhook.kt model with full API shape - New HermesApiService endpoints for CRUD operations Closes #447 * fix: remove double top padding on webhooks screen LazyColumn * fix: use string resources in webhooks screen (sweep hardcoded strings) * fix: resolve unresolved merge conflict in AchievementsScreen Leftover <<<<<<< markers from a main merge broke compilation on the webhooks-full-parity branch, cascading through ktlint/lint/tests. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com> |
||
|
|
f887146056 |
feat(#433): achievements tab — full dashboard parity (#450)
* 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> |
||
|
|
58bd78bd79 |
feat: Activity feed — cron deliveries with output (#420)
* 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> |
||
|
|
4c075be026 |
feat: cron job editor dialog (closes #150)
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 |
||
|
|
4849f4c1c6 |
feat(#195): chat attachment support for files and images (#418)
* 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> |
||
|
|
c64b025a54 |
feat(#369): rewrite channels tab with full backend integration (#417)
* 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> |
||
|
|
2ae6cce929 |
fix(#372): plugin install/uninstall 400 — enable instead of install for on-disk plugins, gate uninstall behind canRemove, parse FastAPI error bodies (#416)
- Replace installPlugin(name) with activatePlugin(plugin) that calls enable endpoint instead of the install endpoint for plugins already on disk (bundled/inactive). - Gate Uninstall button behind plugin.canRemove — bundled plugins don't show it. - Update PluginsScreen to call activatePlugin(plugin) instead of installPlugin(name). - Parse FastAPI 'detail' field from 400 response bodies in mapHttpError/safeApiCall so users see the actual error message instead of a generic placeholder. - Import ButtonDefaults to fix max-line-length lint warning. Co-authored-by: M57 <hy4ri@users.noreply.github.com> |