Commit Graph
158 Commits
Author SHA1 Message Date
M57andM57 5e1ee0b4e5 feat(chat): desktop-parity session resume — warm cache + bounded retry (#766)
Bring the desktop's session reconnection UX to mobile:

- Warm-cache fast-path: switchSession paints the cached Room transcript
  immediately instead of blanking the screen, then refreshes from the
  server in parallel (stale cache can't clobber a fresher server page)
- Bounded auto-retry: a failed session.resume (WS reject or REST
  transcript failure) retries with exponential backoff 1s->2s->4s->8s,
  capped at 4 attempts; WS + REST failures in the same cycle count as one
- Explicit error recovery: after exhaustion the chat shows an error card
  with a manual Retry instead of latching the spinner forever; manual
  retry resets the backoff cycle
- Reconnect-aware resume: gateway (re)connect clears stale error state
  and re-resumes the current session on the new socket
- Unit tests: warm-cache paint, bounded retry exhaustion, WS+REST
  dedupe, manual retry reset, reconnect restart

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-02 00:04:23 +03:00
M57andhy4ri 982f8aec4e feat(chat): rewire tool bubbles onto the desktop engine (stage 3) (#765)
* feat(chat): rewire tool bubbles onto the desktop engine (stage 3)

parseToolOutput is now a thin adapter: it normalizes the payload shape
(args/result sub-objects, legacy top-level fallback, name resolution)
and delegates all per-tool formatting to ToolViewBuilder.

- ToolResultParser.kt: 1325 lines of bespoke formatters → 45-line adapter
- ParsedToolData deleted; ToolBubble renders ToolView directly:
  collapsed = emoji + engine title (+countLabel) + subtitle line;
  expanded = error → $ command / stdout / stderr / exit code →
  DiffViewCard → search hits → detail → duration footer
- Gson-compat shims, formatTodoToolOutput, toIntOrNull and all
  per-tool formatters died with the old parser (engine is idiomatic kotlinx)
- Kept mobile-only UI: progress preview, SecurityRiskChip, emoji
  prefixes, raw-JSON toggle, DiffViewCard, copy
- ToolViewBuilder fixes found during the rewire: numberValue now accepts
  numeric strings (TS Number() semantics — gateway ships exit_code as
  string/float), memory title keeps its target suffix

ToolBubbleParsingTest rewritten as adapter-contract tests (14 cases);
per-tool behavior lives in ToolViewBuilderTest. Verified: ktlint +
full testDebugUnitTest.

* fix(chat): dedupe tool name in collapsed bubble summary

The header row already shows the tool name, but the collapsed summary
showed emoji + engine title — and for ~11 tools the engine title is just
the generic humanized name (fact_store -> "Fact Store", todo -> "Todo"),
so the bubble displayed the tool identity twice.

composeToolSummaryLines now drops the title when it equals the generic
humanized name and merges the subtitle into the emoji line instead
("🧠 Fact added (ID: 5)"), restoring the pre-engine one-line style.
Descriptive titles ("Searched \"cats\"", "Ran ls", "Read a.kt L10-14")
stay on line one with the subtitle on line two. A lone emoji with no
subtitle renders nothing.

ToolViewBuilder.genericTitleFor exposes the generic title for the
comparison. 9 new unit tests.

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-08-01 23:23:11 +03:00
M57andhy4ri 100a82dd4f feat(chat): port desktop tool display engine (ToolResultSummary + ToolViewBuilder) (#763)
* feat(chat): port desktop heuristic JSON summarizer engine

New com.m57.hermescontrol.ui.chat.tool.ToolResultSummary — a faithful
Kotlin port of the desktop app's lib/tool-result-summary.ts (verified
against its vitest suite, ported 1:1 to JUnit):

- formatToolResultSummary: wrapper unwrap (data/result/output/response/
  payload, up to 4 levels), priority-key ordering, array/record block
  formatting with depth caps, noise filtering (success:true etc.)
- extractToolErrorMessage: cycle-safe nested hunt for real error text
  through wrappers, with placeholder filtering (error:'none' ignored)
  and stderr deliberately excluded as an error signal

Also handles stringified-JSON payloads (re-parse on demand) like the
desktop's tryJson/norm layer.

Ported contract tests: ToolResultSummaryTest (10 cases).

* feat(chat): port desktop per-tool display engine (ToolViewBuilder)

Second half of the desktop engine port, in the new ui.chat.tool package:

- ToolView: the render model the builder produces — status/title/subtitle/
  detail/count/duration/stdout/stderr/exitCode/diff/searchHits/imageUrl —
  no raw JSON in UI code
- ToolViewBuilder: port of the desktop buildToolView (fallback-model):
  status heuristics (explicit success wins; non-zero exit alone is NOT an
  error), dynamic titles (Read/Searched/Ran with summarized commands),
  per-tool subtitle/detail (browser snapshot stats, terminal output
  preview, memory message, cron job counts), count-label cascade,
  web_search hit parsing, inline diff + stats, stdout/stderr split,
  duration formatting, redundant-detail suppression
- Mobile-specific tool cases carried into the builder: todo, fact_store,
  session_search, skills_list/view, skill_manage, process, x_search,
  vision_analyze, tool_search, image_generate, project_*, read_terminal,
  computer_use
- CommandSummarizer: port of the desktop summarize-command.ts (quote-aware
  compound split, pipe-tail strip, redirect/env cleanup)
- Terminal stdout reads the gateway's canonical merged `output` key with
  stdout/stderr fallback (mobile terminal_tool contract differs from
  desktop)

Tests: ToolViewBuilderTest (18), CommandSummarizerTest (8), all ported
or derived from the desktop suite. Full suite green: 655 tests.

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-08-01 22:01:13 +03:00
M57andM57 3a96edd4c1 fix(chat): clear stale reasoning when a new message starts (#755) (#760)
A new assistant message was seeded with the previous message's
reasoningText (and isReasoning flag) via onMessageStart, and
resetStreaming() never cleared the shared streaming state. If the
next reply produced no reasoning, the stale trace stayed glued to
the new bubble and got persisted to Room.

- onMessageStart: new message + StreamingState start with empty
  reasoning instead of inheriting the previous turn's
- resetStreaming: clears isReasoning + reasoningText on the shared
  state (finalized messages keep their own copy for the card)
- regression tests: MessageStart seeding, no-reasoning persistence,
  reset clearing, finalized-message preservation

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-01 20:14:35 +03:00
M57andM57 e014d2a0e7 fix(chat): context meter reflects compression via session.context_breakdown RPC (#759)
* fix(chat): context meter reflects compression via session.context_breakdown RPC

The chat context meter read GET /api/sessions/{id} input_tokens — a
cumulative lifetime counter that compression never resets, so the meter
only ever grew and sat red after the first compaction.

Mirror the desktop: read live occupancy from the session.context_breakdown
WS RPC (context_used = compressor last_prompt_tokens, falling back to an
estimate of the live system prompt + tools + compacted history). The
compressor zeroes last_prompt_tokens when it runs, so the meter now drops
right after auto-compaction or /compress, exactly like desktop/web.

REST getSessionDetail is kept solely to feed the detail sheet's cumulative
token accounting. RPC failures keep the last known values; the meter never
blanks. Fixes #756.

* feat(chat): compressed xN badge on the context widget

The context chip now shows how many times the current session has been
context-compressed, sourced from the session.usage WS RPC (compressions =
context_compressor.compression_count) — the same usage snapshot the desktop
status bar reads. The badge explains a meter drop after auto-compaction or
/compress: the widget no longer just shows the number, it shows the story.

- UsageSnapshotResponse model + parseUsageSnapshot (Number-safe like the
  breakdown parser — WS parser decodes JSON numbers as Double)
- compressionCount added to ChatUiState, reset on session switch/create/branch
- session.usage fires alongside session.context_breakdown in the 5s poll;
  failures keep the last known badge value (best-effort, never blank)
- 3 parser tests; full local suite 615 tests green

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-01 19:46:49 +03:00
M57 63b966b133 feat(chat): add inline video player component and modal viewer (#722) (#732)
* feat(chat): add inline video player component and modal viewer

Closes #722

* feat(chat): modernize video player with compact lock pill, save, and share

* refactor(chat): simplify inline video thumbnail to launch fullscreen player on tap

* fix(chat): pass session cookie and auth token headers to VideoView for protected media playback

* fix(chat): format unquoted cookie header for VideoView auth

* fix(chat): resolve video URLs via resolveImageUrl for gateway-hosted media
2026-08-01 00:47:26 +03:00
M57andM57 1b596746af feat(chat/activity): Self-improvement event badges for memory & skill updates (#744)
* feat(chat/activity): Self-improvement event badges for memory & skill updates (#737)

* feat(chat): Handle background review.summary WS events for self-improvement notices

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-29 21:43:08 +03:00
M57andM57 2cf41ead06 feat(chat): live task delegation widget with inline card & sticky progress indicator (#736) (#743)
* feat(chat): live task delegation widget with inline card & sticky progress indicator (#736)

* fix(chat): refine subagent isRunning status and fallback Compose list keys (#743)

* feat(ci): add CodeQL analysis workflow & integrate todos into task widget (#736)

- Add CodeQL workflow with manual build mode for java-kotlin
- Render agent plan & todo items inside live delegation widget

* ci(codeql): add advanced CodeQL workflow with continue-on-error for Kotlin 2.4.10 (#736)

* ci(codeql): use build-mode none to prevent Kotlin 2.4.10 extractor error (#736)

* revert(ci): remove codeql workflow (#736)

* refactor(chat): remove inline task card and rely on sticky top bar (#736)

- Keep StickySubagentBar and SubagentInspectionSheet for top widget UI

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-29 18:15:39 +03:00
M57andM57 8ba92f2567 feat(chat): interactive diff view for file edit and patch tool output (#738) (#741)
* feat(chat): interactive diff view for patch and file edit tool output

* fix(chat): address agy review feedback for diff viewer UX and layout

* fix(chat): suppress raw text/json when diff view renders and fix sync message ordering race (#741)

* fix(tests): resolve nullable smart-cast errors in ToolBubbleParsingTest

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-29 12:05:56 +03:00
AlexDev_ e39544ebd5 fix: keep transient ticket failures reconnecting (#740)
* fix: keep transient ticket failures reconnecting

* fix: recover rejected websocket sends

* fix: serialize websocket reconnect scheduling
2026-07-28 22:33:12 +03:00
M57andM57 13895585b0 test(chat): lock /init + /focus command.dispatch contract (issues #733 #734) (#735)
Both commands are real backend command.dispatch subcommands (name=="init" / name=="focus", hermes-agent 2b0fb72ac). Mobile already routes them generically via RpcDispatch (name/arg/session_id) and handles responses (type:send -> submitPrompt for /init, type:exec -> assistant message for /focus) + surfaces them from commands.catalog.

Adds 6 contract tests mirroring the existing /help + /queue + /status cases:
- /init and /focus forward name+arg+session_id to COMMAND_DISPATCH
- /init with extra arg and /focus on/off forward the arg
- type:send (init) submits the returned prompt via sendMessage
- type:exec (focus) surfaces output as an assistant message

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-27 20:05:22 +03:00
M57 5c1492a80e feat(chat): add animated GIF rendering with tap-to-play animation (#721) (#731)
* feat(chat): add animated GIF rendering with tap-to-play animation

Closes #721

* fix(#721): add missing GifImageThumbnail component and format ktlint

* fix(analytics): resolve ktlint indentation error in itemsIndexed

* feat(chat): add interactive play/pause toggle for animated GIFs

* fix(chat): control GIF playback via Animatable start and stop
2026-07-27 18:32:45 +03:00
M57andM57 f45a68b0db feat(chat): full-screen image viewer (issue #723) (#729)
* feat(chat): full-screen image viewer (issue #723)

Tap any chat image (markdown ![alt](url), inline attachment thumbnail,
or bubble image) to open a full-screen viewer:
- pinch-zoom + pan via native detectTransformGestures
- downswipe-to-dismiss at min zoom
- Save -> device Downloads (MediaStore), Share -> Android share sheet
  (both fully device-local; never sent back to the Hermes server)

Plumbing: onImageClick(ImageViewerModel) flows MarkdownText ->
ChatBubble/InlineAttachment -> ChatMessageList -> ChatScreen (state +
ImageViewerDialog). ImageBytesResolver reuses the existing authed
OkHttpProvider path for gateway http(s) sources; data:/content://
handled locally. No new dependencies.

Closes #723

* fix(chat): resolve strings at composable scope to satisfy LocalContextGetResourceValueCall lint

Lint forbids reading resource *values* from LocalContext.current inside
coroutine lambdas. Hoist all viewer strings via stringResource() at
composable scope and reuse them in onSave/onShare.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-27 15:21:19 +03:00
M57andM57 a1155e8015 feat(#724): full gateway-file support — attach MEDIA: files (images, audio, video, docs) (#725)
* 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
![image](<baseUrl>/api/files/download?path=<enc>&token=<enc>) 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>
2026-07-27 14:52:49 +03:00
M57andM57 439ae9fcba feat: gateway files client — fetch any gateway-hosted file over HTTP (#726)
* feat: gateway files client — fetch any gateway-hosted file over HTTP

Add GatewayFileClient: a typed client for the gateway's managed-files
download endpoint (GET /api/files/download?path=<enc>&token=<enc>), which
streams the raw bytes of any file living on the gateway host. This is the
mobile equivalent of desktop's mediaExternalUrl() and works on a remote
phone (real HTTP, not a host-local file read).

- buildDownloadUrl(): pure authed-URL builder (null on blank creds /
  non-absolute path), mirrors desktop's token-in-query auth.
- fetch(): OkHttp GET via the shared client, maps 401/403/404/413 to typed
  GatewayFileResult variants; returns GatewayFile(name, mime, bytes).
- MediaTypes: IMAGE/AUDIO/VIDEO/FILE classification by extension (mirrors
  desktop MEDIA_BY_EXT) so future consumers can present images, audio,
  video, or generic files — beyond just images.

Pure builders/classifiers are unit-tested. Mobile-only, backend untouched.
This is the foundation PR; issue #724's MEDIA: rendering will consume it.

* fix: decode filename + handle download-URL in mediaNameFromPath

- GatewayFileClient.parseFilename now URL-decodes the captured value so
  filename*=UTF-8''a%20b.png yields 'a b.png'.
- MediaTypes.mediaNameFromPath extracts the filename from the percent-encoded
  path= query param of a full /api/files/download?path=<enc> URL (the real
  case), not just a bare path.

* fix(remote): fix unit tests for gateway file client and media parsing

Replace plus-encoded spaces with %20 in download URL builder, URL-decode filenames in parseFilename, and extract target filename from URL path parameters.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-26 21:02:26 +03:00
M57andM57 50fdc12992 feat(chat): render images + attachments in assistant bubbles (#720)
Assistant messages could not display inline images or attachments on the
mobile client. The markdown renderer had no image block, and
AssistantBubble only rendered MarkdownText + timestamp, ignoring
message.attachments (only UserBubble did).

- Add MdBlock.Image + standalone ![alt](uri) parsing in MarkdownText.
  Accepts http(s) gateway URLs and data:image/...;base64 inline media.
- Render message.attachments inside AssistantBubble (mirrors UserBubble)
  via InlineAttachment / AsyncImage.
- Add parser unit tests for image blocks (url, data: URL, inline-in-paragraph).

Local verification: ./gradlew ktlintCheck compileDebugKotlin assembleDebug
all green; app-debug.apk produced.

Fixes feature gap where agent-delivered media (e.g. MEDIA:/ images
sent to chat) was invisible in the Android app.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-26 17:48:03 +03:00
M57andM57 0180fb12c2 Add used/full context meter to chat screen (#717)
* 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>
2026-07-26 01:52:22 +03:00
M57andM57 c181d1d593 Adopt session.redirect (issue #710) (#714)
* Add session.redirect affordance (issue #710)

Route send through session.redirect while a turn is streaming and the prompt
is plain text (no attachments, not a /command); otherwise prompt.submit.
Adds WsMethods.SESSION_REDIRECT + HermesWsClient.sendRedirect. No separate
button — the composer already sends mid-turn, so send just does the redirect.

* fix(chat): capture typing state before sendMessage for redirect

Capture wasStreaming state before updating uiState.isAgentTyping so normal sends call prompt.submit.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-25 18:15:39 +03:00
M57andM57 4e2fb05077 fix(chat): preserve reasoning across streaming transitions (#709)
Flush throttled reasoning before state-transition events (MessageStart/
Complete/Done/ToolStart) so a reasoning delta arriving just before a
transition is not dropped. Also compare reasoningText in sameMessages so
a reasoning-only restore actually re-merges.

- ChatStreamingController.flushPendingReasoning() forces a synchronous flush
- handleWsEvent calls it for the transition events
- sameMessages now includes reasoningText in its equality check
- tests: ChatStreamingControllerTest (flush + no-resurrect), reasoning survives
  MessageDone after an unflushed delta

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-25 13:03:46 +03:00
M57andM57 8c13f9bed0 perf: optimize synchronous I/O in AuthLoginViewModel (#699)
* perf: optimize synchronous I/O in AuthLoginViewModel

Replace blocking OkHttp .execute() with a suspending .await() extension
(CallExt.kt) backed by suspendCancellableCoroutine, freeing Dispatchers.IO
threads during network waits. Probe/connect paths made suspend.

- Call.await() guards both onResponse/onFailure with continuation.isActive
  to avoid AlreadyResume on cancellation.
- Removes non-asserting AuthLoginViewModelBenchmark timing dump.

* perf: optimize synchronous I/O in AuthLoginViewModel

Replaced the blocking `.execute()` OkHttp calls with a suspending `.await()` extension function inside `AuthLoginViewModel`. This frees up the `Dispatchers.IO` threads by deferring the blocking operations to OkHttp's internal task runner, providing a safer async approach and preventing Thread pool starvation on slow network responses.

Modified `probeDashboardInternal` and `connectBasicAuth` to be proper `suspend` functions and resolved corresponding unit tests.

* fix(test): add missing import io.mockk.any in AuthLoginViewModelTest

* fix(test): fix mockk imports and Log matchers in AuthLoginViewModelTest

* fix(test): use persistent mockProbeClient in AuthLoginViewModelTest

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-23 22:23:53 +03:00
M57andM57 152b7d5c0d fix(#700): omit Bearer header on gated-mode REST calls (#701)
In gated mode (basic-auth cookie flow) the dashboard 401s any request that
carries an Authorization: Bearer header alongside the valid session cookie
(verified live 2026-07-23). ApiClient.authInterceptor stamped the WS ticket
as a Bearer header on every REST call, so every non-chat tab (skills, cron,
config, tools, mcp, ...) failed with "token expired" while the WS chat
(?ticket=) kept working.

- Add AuthManager.isGatedMode() (wsAuthParam == "ticket").
- authInterceptor skips the Bearer header in gated mode; loopback/token
  behavior unchanged.
- TokenRefreshAuthenticator bails in gated mode instead of attempting the
  loopback-only SPA token refresh.
- ApiClientTest: add gated/loopback regression tests. 7/7 pass, ktlint clean.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-23 14:46:36 +03:00
M57 b766574627 Add comprehensive tests for loadSettings in SettingsViewModel (#697)
* test: add comprehensive tests for loadSettings in SettingsViewModel

Adds `testLoadSettings_loadsAllProperties` to `SettingsViewModelTest.kt` to thoroughly verify that `loadSettings()` correctly maps all required settings properties from `AuthManager` to the `uiState`. The test utilizes `runTest` and `advanceUntilIdle()` to properly handle testing the coroutine launch in the ViewModel's init block.

* test: add comprehensive tests for loadSettings in SettingsViewModel

Adds `testLoadSettings_loadsAllProperties` to `SettingsViewModelTest.kt` to thoroughly verify that `loadSettings()` correctly maps all required settings properties from `AuthManager` to the `uiState`. The test utilizes `runTest` and `advanceUntilIdle()` to properly handle testing the coroutine launch in the ViewModel's init block. Fixes ktlint formatting issues on imports.
2026-07-23 13:17:00 +03:00
M57 1dd59efaab [Add edge case tests for DashboardSessionTokenRefresher] (#694)
* Add edge case testing for DashboardSessionTokenRefresher

Added tests to `DashboardSessionTokenRefresherTest.kt` to cover
cases where `refresh()` catches exceptions or where `fetch()`
returns null. Included a happy-path test for `refresh()`.

* Add edge case testing for DashboardSessionTokenRefresher

Added tests to `DashboardSessionTokenRefresherTest.kt` to cover
cases where `refresh()` catches exceptions or where `fetch()`
returns null. Included a happy-path test for `refresh()`.
2026-07-23 13:15:23 +03:00
M57 d3836ace7f [Testing Improvement] Add mapping tests for ChatMessageEntity (#693)
* 🧪 test: Add mapping tests for ChatMessageEntity to/from ChatMessage

* Added comprehensive tests to `ChatMessageMapperTest.kt` verifying the pure mappings between Room database entities (`ChatMessageEntity`) and UI models (`ChatMessage`).
* Verified fallback logics for enums (MessageRole and ToolStatus).
* Ensures regression prevention when underlying data models change.

* 🧪 test: Refactor mapper boolean assertions to assertTrue/assertFalse

* Changed assertEquals(true/false, isStreaming) to assertTrue/assertFalse
as suggested in the review.
2026-07-23 13:14:12 +03:00
M57 beb71b9e7a Hoist repeated regex compilations in Markdown parsing (#695)
* perf: Hoist regexes in MarkdownText.kt to avoid recompilation

Resolves a performance issue where `bulletRe`, `taskRe`, and `orderedRe`
were instantiated and compiled inside the `parseBlocks` function on every
call, causing unnecessary overhead during repeated markdown parsing (e.g.,
when chat messages stream in).

These regex objects have been hoisted to file-level `private val`
constants (`BULLET_RE`, `TASK_RE`, `ORDERED_RE`), ensuring they are compiled
only once when the class is loaded.

A benchmark `MarkdownTextBenchmark.kt` was added to verify the
performance change, showing an approximate 50ms improvement over 10k
iterations.

* perf: Hoist regexes in MarkdownText.kt to avoid recompilation

Resolves a performance issue where `bulletRe`, `taskRe`, and `orderedRe`
were instantiated and compiled inside the `parseBlocks` function on every
call, causing unnecessary overhead during repeated markdown parsing (e.g.,
when chat messages stream in).

These regex objects have been hoisted to file-level `private val`
constants (`BULLET_RE`, `TASK_RE`, `ORDERED_RE`), ensuring they are compiled
only once when the class is loaded.

A benchmark `MarkdownTextBenchmark.kt` was added to verify the
performance change, showing an approximate 50ms improvement over 10k
iterations.

* perf: Hoist regexes in MarkdownText.kt to avoid recompilation

Resolves a performance issue where `bulletRe`, `taskRe`, and `orderedRe`
were instantiated and compiled inside the `parseBlocks` function on every
call, causing unnecessary overhead during repeated markdown parsing (e.g.,
when chat messages stream in).

These regex objects have been hoisted to file-level `private val`
constants (`BULLET_RE`, `TASK_RE`, `ORDERED_RE`), ensuring they are compiled
only once when the class is loaded.

A benchmark `MarkdownTextBenchmark.kt` was added to verify the
performance change, showing an approximate 50ms improvement over 10k
iterations.
2026-07-23 13:13:08 +03:00
M57 dcf26f1a16 Add comprehensive tests for ServerCollectionOps (#696)
* Test ServerCollectionOps data manipulation utilities

Added comprehensive unit tests for `ServerCollectionOps.kt` to cover all scenarios for `addOrUpdate`, `selfHealed`, and resolution properties (`resolvedBaseUrl`, `resolvedHost`, `resolvedPort`). Also updated the function name `addOrReplaceServer` to `addOrUpdate` to match the intended signature in the issue description, and updated affected call sites.

* Fix ktlint formatting issues and ensure build succeeds

Fixed ktlint errors in `ServerCollectionOpsTest.kt` by correctly formatting arguments and expressions to comply with project style guidelines. Ran `assembleDebug` and tests locally to verify build health.
2026-07-23 13:04:10 +03:00
M57 992de56059 [Test] Add unit tests for ChatPersistenceRepository (#692)
* test: add unit tests for ChatPersistenceRepository

Added ChatPersistenceRepositoryTest to verify the repository
correctly delegates to the ChatMessageDao and maps between
ChatMessage UI models and Room entity objects. Uses the
existing FakeChatMessageDao for reliable in-memory testing.

* test: fix ktlint issues in ChatPersistenceRepositoryTest

Formatted ChatPersistenceRepositoryTest to comply with standard
ktlint rules for the project.

* test: enhance ChatPersistenceRepositoryTest coverage

Added role and content assertions to `persistMessages_upsertsAllToDao`
and added a new `loadMessages_filtersBySessionId` test to verify
session isolation per PR feedback.
2026-07-23 13:00:16 +03:00
M57 7927944198 Revert "fix(#688): route expired auth sessions to sign-in (#689)" (#698)
This reverts commit a5baf41545.
2026-07-23 12:40:29 +03:00
M57 a5baf41545 fix(#688): route expired auth sessions to sign-in (#689)
* fix(#688): route expired auth sessions to sign-in

Adopt global 401 latch, WS disconnect, and Back/profile guards.

* chore: clean up formatting noise

* fix(ktlint): reorder imports to place java and kotlinx at end

* fix(ktlint): place java.io.IOException at end of imports

* test: fix NavBackStack type parameter in AuthenticationRoutingTest

* test: add AuthSessionState tests to AuthLoginViewModelTest

* fix(ktlint): fix import order in AuthLoginViewModelTest
2026-07-23 00:44:17 +03:00
M57andM57 def122ab58 fix(#686): implement robust history pagination (#687)
* 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>
2026-07-23 00:30:48 +03:00
M57andM57 2e97ee9a84 refactor: remove verified dead code across data, UI, and resources (#680)
* refactor: remove unused dead code across data, UI, and resources

* fix: resolve dangling references left by dead-code removal

Restore empty object Motion (braces required) and remove the 12 stale
test usages that still called the now-deleted production symbols:
- ServerStoreTest: removeServer/switchToServer tests
- ServerEndpointTest: isCleartext assertions
- ConnectViewModelTest: onHostChange/onPortChange tests + 3 dropped R.string refs
- SettingsViewModelTest: renameProfile tests + clearNavigateToLogin
- E2eIntegrationTest: previewSkill tests + onHostChange/onPortChange calls

Verified locally: compileDebugUnitTestKotlin + 48 tests pass.

* fix: remove unused imports flagged by ktlint

Drops 4 no-longer-referenced imports left by the dead-code removal:
- HermesApiService: SkillScanResponse (scanHubSkill removed)
- ServerEndpointTest: assertFalse, assertTrue (isCleartext assertions dropped)
- SettingsViewModelTest: assertFalse (rename tests removed)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-22 15:51:07 +03:00
M57andM57 1cca7764cc perf: optimize data loading across Config, System, Profiles, and Billing tabs (#678)
* perf(model): optimize model tab load speed with two-phase fetch and loadQuick

* perf: optimize data loading across Config, System, Profiles, and Billing tabs

* chore: remove leftover bottom nav references and unused updatePrimaryScreens

* style: fix ktlint violations in Config/Profiles/Billing ViewModels

- property/argument-list wrapping for async(...) calls
- multiline-expression wrapping for async blocks
- remove unused import in ConfigViewModel
- break long error-message string literals to stay under 120 cols

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-22 15:03:30 +03:00
M57andM57 56de3323f0 fix(chat): disable unusable history pagination (#676)
When the initial REST page returns empty messages, hasOlderMessages
was still set to true (offset > 0), causing an infinite loop of empty
load-more requests. Guard with && chatMessages.isNotEmpty().

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-22 12:09:29 +03:00
M57andM57 0edb67338d refactor: remove bottom navigation bar (#665 #666 #667 #668) (#672)
* refactor(nav): remove bottom NavigationBar from Scaffold (#665)

* refactor(theme): remove BottomNavDisplayMode enum (#666)

* refactor(settings): remove nav bar customization page (#667)

* chore: clean up unused imports after bottom nav removal (#668)

* chore: remove dead bottom nav infrastructure after removal

- Remove SettingsNavBar NavKey (NavigationKeys.kt)
- Remove bottomNavItems field from ServerStoreState + migration code
- Remove _bottomNavItemsFlow, getBottomNavItems, setBottomNavItems from AuthManager
- Remove bottomNavItems handling from selfHealed() (ServerCollectionOps)
- Remove redundant bottomBar={} from Scaffold (Navigation.kt)
- Remove unused nav bar strings from values/strings.xml + values-ko/strings.xml
- Remove content_desc_move_up/down/remove_item strings
- Update SettingsViewModelTest.kt (drop stale getBottomNavItems mock)
- Update ServerStoreTest.kt (remove bottomNavItems assertions)

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-22 10:14:50 +03:00
M57andM57 a62bc7e16c refactor: remove device Pairing feature entirely (issue #645) (#650)
* refactor: remove device Pairing feature entirely (issue #645)

- Delete Pairing.kt model + pairing API endpoints (get/approve/revoke/clear)
- Delete PairingScreen/PairingViewModel + PairingCodeEntryScreen/ViewModel
- Remove NavKeys, ScreenRegistry drawer entry, Navigation.kt entry + Landing pairing button
- Remove pairing string resources (en + ko); keep connect_* config-parse strings
- Keep Telegram onboarding pairing_id flow (separate concept, per issue)
- Fix related doc comments + remaining test reference

* test: fix racy CONNECTED read in gated-mode WS test (issue #635 test)

testGatedMode_attemptsTicketFetchWithoutBareCookie asserted CONNECTED
immediately after the server-side onOpen latch fired, racing the client
handshake (WsListenerImpl sets CONNECTED on receipt of the 101). Await the
real status transition via connectionStatus.first{==CONNECTED} like the
other connect tests do. No product behavior changed.

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-20 23:23:29 +03:00
M57andM57 e44a53366b Fix #647: resolve Default profile URL from top-level login URL (#649)
* Fix #647: resolve Default profile URL from top-level login URL

ConnectionProfile.resolvedBaseUrl (used directly by ConnectionSection for
the profile list display) silently fell back to the hardcoded loopback
127.0.0.1:9119 whenever a profile's own baseUrl was null, instead of using
the URL the user actually authenticated against.

- Add ConnectionProfile.resolveBaseUrl(topLevelBaseUrl) that prefers the
  profile's own baseUrl, then the top-level login URL, then legacy loopback.
- Store-level ServerCollectionOps.resolvedBaseUrl now applies the same
  fallback so a null-baseUrl profile still shows the real gateway.
- ConnectionSection, SettingsViewModel and ConnectViewModel resolve against
  AuthManager.getBaseUrl() so the loopback fallback can never surface.
- Add Issue647ProfileUrlTest covering login-stamp, null-baseUrl fallback,
  own-URL precedence, fresh-install loopback, and store-level resolution.
- Update existing AuthManagerTest assertions to the new property form.

* Fix #647 (root cause): cache login URL at top-level baseUrl

The previous change only made profile resolution prefer the top-level
login URL, but setBaseUrl() never actually wrote the top-level baseUrl
field — so it stayed DEFAULT_BASE_URL (https://127.0.0.1:9119/) forever.
A Default profile seeded with the loopback default (or whose own baseUrl
was never stamped) therefore still displayed the loopback URL after
logging in via a custom LAN address.

setBaseUrl() now also stamps the top-level baseUrl, making it the
authoritative "last successful login URL" cache. getBaseUrl() and the
store-level resolvedBaseUrl (profile-list display) now reflect the URL
actually authenticated against.

Add regression tests mirroring the exact AuthLoginViewModel.connect()
sequence (seed with loopback -> setBaseUrl(loginUrl) -> token) asserting
the Default profile, top-level baseUrl and store display all resolve to
the login URL, never loopback.

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-20 22:03:31 +03:00
M57andM57 c8a5055a84 fix(auth): support __Host-/__Secure- prefixed session cookies and proxy-relative paths (issue #635) (#648)
- CookieSerialization: add SESSION_COOKIE_NAMES (__Host-, __Secure-, bare) and
  isSessionCookieName(); isSessionCookie now matches any variant.
- PersistentCookieJar.getSessionCookieValue(): iterate SESSION_COOKIE_NAMES,
  returning the strictest prefixed match when present.
- HermesWsClient.refreshWsTicketIfNeeded(): drop the bare-name session-cookie
  preflight in gated mode — the server may hold a valid refresh cookie under a
  prefixed name; the authenticated ticket endpoint is the authority. Ticket URL
  already derived from endpoint (issue #634).
- HermesWsClient: stop logging WS close/failure reason strings and exception
  messages (class name only) to avoid leaking server context / URLs.
- ProfileScopeInterceptor: match profile-scoped paths via endpoint-relative
  request path so reverse-proxy prefixes (e.g. /hermes/api/...) are honored;
  wrap endpoint read in runCatching.

Verified: ktlintCheck, testDebugUnitTest, lintDebug all green locally.

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-20 20:56:39 +03:00
M57andM57 e18b25d065 fix(ux): keep Settings highlighted in bottom nav on drill-down sub-pages (issue #637) (#643)
* fix(ux): keep Settings highlighted in bottom nav on drill-down sub-pages (issue #637)

Map Settings drill-down destinations (SettingsConnection, SettingsAppearance, SettingsChat, SettingsNavBar, SettingsBehavior, SettingsAbout) back to SettingsScreen for bottom-nav selection only - navigation is unaffected.

- Add selectedBottomNavDestination() helper in Navigation.kt
- Extract bar-height logic into Modifier.bottomNavigationHeight() (ICON_AND_TEXT now uses heightIn(min=80.dp) so the standard bar can grow)
- BottomNavigationSelectionTest (JVM): selection mapping logic
- BottomNavigationLayoutTest (androidTest): per-mode bar height assertions

* docs(navigation): warn that new Settings sub-pages must be added to settingsDestinations

Addresses agy review (PR #643): documents the fragility of the hardcoded settingsDestinations set so future Settings drill-down screens don't silently drop the bottom-nav highlight. The sealed-class/marker-interface refactor was declined as scope creep (issue #637 explicitly scopes Settings-only; keys are flat data objects).

* fix: language selector

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-20 18:26:05 +03:00
M57andhy4ri 26b104b2bb [feature] issue #634: HTTPS + reverse-proxy server endpoint support (#638)
* [feature] issue #634: HTTPS + reverse-proxy server endpoint support

- Add ServerEndpoint model (scheme-aware base URL, resolve/webSocketUrl/relativeRequestPath, cleartext policy)
- AuthManager: baseUrl is canonical; setHost/setPort derive from endpoint; endpointForBuild() for scheme-aware URLs
- ApiClient/CookieManager/HermesWsClient: route through ServerEndpoint (no hardcoded http://)
- Data migration: ServerUrlMigration converts legacy host/port to baseUrl
- UI: Connect/AuthLogin/Settings now use a single Server URL field with transport warning
- Build: ALLOW_CLEARTEXT buildConfigField + manifest placeholder for dev
- Tests: ServerEndpointTest + ServerUrlMigrationTest; adapt existing suite to baseUrl API

Co-Authored-By: M57 <Hy4ri@users.noreply.github.com>

* fix(authlogin): basic-auth derives token automatically, no manual entry

connectBasicAuth() built a fresh OkHttpClient for the password-login call and
a different one for the ws-ticket call, so the Set-Cookie from
/auth/password-login (the session token lives in the cookie, not the JSON body)
never reached /api/auth/ws-ticket. That 401'd the ticket request, returned null,
and the UI fell back to asking the user to paste a token.

Both calls now use the shared OkHttpProvider.probe client (same CookieManager
jar), so the session cookie flows through and the WS ticket mints automatically —
matching the dashboard contract (routes.py: password-login sets Set-Cookie,
ws-ticket requires it).

Add AuthLoginBasicAuthFlowTest proving the cookie propagates on the shared
client (and that ws-ticket 401s without it).

Fixes the basic-auth token prompt reported against issue #634.

* chore: remove TASK-634-TESTS.md (stray task-tracking doc, not part of feature)

* ci: broaden .gitguardian.yml test-file ignores (unit + instrumented + variant dirs)

* feat(authlogin): smart auth-mode detection from /api/status + drop pairing-string login

- Replace 302-sniff probe with authoritative GET /api/status read
  (auth_required + auth_providers) to derive DashboardAuthMode.
- Add OAUTH mode (stubbed, 'coming soon' UI, TODO tracks issue #639).
- Render login UI by detected mode: token (loopback), username+password
  (basic/gated), or OAuth notice.
- Remove legacy pairing-string box + onPairingString/applyLegacyPairing.
- Extract internal deriveAuthMode() + add 5 unit tests.

Closes nothing; OAuth tracked in #639.

* fix(chat): don't block WS connect on empty token in gated (ticket) mode

connectWebSocket() gated on getToken() != null, correct for loopback
(token) mode but wrong for gated (ticket) mode: the ticket is minted
fresh by HermesWsClient.refreshWsTicketIfNeeded() from the persisted
session cookie, so getToken() is expected empty at connect time. The
early return left the WS unopened -> DISCONNECTED -> chat showed
'reconnect' immediately after basic-auth login.

Only require a non-blank token in non-gated mode.

Fixes #640.

* test: remove onPairingString test cases for deleted pairing-login path

The pairing-string login (onPairingString/applyLegacyPairing) was removed
from ConnectViewModel, so the 5 tests that exercised it (3 in
ConnectViewModelTest, 2 in E2eIntegrationTest) no longer compile. Remove
them. Also drop the now-orphaned connect_pairing_string from values-ko.

Fixes the Unit & Integration Tests CI failure on PR #638.

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-07-20 17:26:10 +03:00
M57andhy4ri 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>
2026-07-15 23:57:39 +03:00
M57andM57 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>
2026-07-15 23:43:00 +03:00
M57andhy4ri 0823fe3117 fix(#599): place cursor at end of text when a slash command is inserted from the dropdown (#600)
Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-07-15 10:36:18 +03:00
M57andM57 5376d46683 feat: queue prompts while the agent is running (#598)
Allow the chat input to be sent while the agent is mid-turn or awaiting
approval. The backend's prompt.submit busy-input policy already queues any
prompt that lands during a running turn (tui_gateway/server.py:_handle_busy_submit)
and drains it as the next turn, so the message is never dropped.

- ChatInputPolicy: pure, testable send/placeholder decision logic
- ChatScreen: canSend stays enabled while typing/approving; placeholder shows
  'Queued — sends when agent is free' when text is present during a busy turn
- ChatInputPolicyTest: truth-table covering typing, approval-pending,
  disconnected, empty, attachment, and slash-command cases
- strings: chat_input_placeholder_queue

Note: this supersedes the Genesi599 fork's approach (dispatchViaRpc('queue ...')),
which double-submits and relies on a 4018-prone command.dispatch path. The
backend-queued prompt.submit path is the correct, single-submission contract.

Co-authored-by: M57 <m57@hermes-control.app>
2026-07-15 01:46:29 +03:00
M57 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
2026-07-14 21:55:37 +03:00
M57andM57 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>
2026-07-14 20:39:33 +03:00
M57andM57 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>
2026-07-14 12:47:59 +03:00
M57andhy4ri 71c1bdded9 fix(#585): render terminal tool output via real backend payload keys (#595)
The backend terminal tool (tools/terminal_tool.py) returns a single merged
'output' string plus 'exit_code' and 'error' — never 'stdout'/'stderr'.
ChatBubble was reading the non-existent 'stdout' key, so command output was
silently dropped and only the Duration footer showed.

- Parse 'output' as canonical; keep legacy 'stdout'/'stderr' fallback for
  backward compat.
- Route terminal output into mainOutput so it renders like other tool bubbles.
- Surface non-zero exit codes as an error line when no 'error' string is set.
- Remove dead 'stderr' render branch.
- Update ToolBubbleParsingTest to assert the real payload shape (incl. the
  exact issue-585 'echo hellp' sample) and add regression cases.

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-07-14 12:01:08 +03:00
M57andM57 29b630a2ec fix(#586): route hardcoded colors through theme tokens (Material You / presets) (#592)
Co-authored-by: M57 <m57@hermes-control.app>
2026-07-14 00:57:12 +03:00
M57andM57 c446715608 fix(#587): forward session_id on image.attach_bytes and await the RPC (#588)
* fix(#587): forward session_id on image.attach_bytes and await the RPC

The mobile image-send path called image.attach_bytes without the required
session_id field, so the gateway resolver (_sess) returned
JsonRpcError(code=4001, message=session not found).

- Add "session_id" to agentSessionId to the image.attach_bytes params
  (desktop reference client already does this; mobile was the outlier).
- Switch the image branch from fire-and-forget wsClient.send to the
  awaited sendRpcAndAwait() layer (same awaited-RPC path used for
  slash commands, #526). This also removes the race where prompt.submit
  could fire before the image was staged, and surfaces RPC errors
  instead of silently swallowing them.

Root cause verified against tui_gateway/server.py _sess() and the
desktop use-prompt-actions reference. ChatViewModel.kt passes ktlintCheck.

Closes #587

* fix(#587): also forward session_id on file.attach (same 4001 bug)

The image branch was fixed in the prior commit, but the file branch
(sendMessage else -> file.attach) omitted session_id too, so non-image
file uploads also hit JsonRpcError(code=4001, "session not found").
The backend file.attach handler (tui_gateway/server.py _sess) requires
session_id exactly like image.attach_bytes.

Add "session_id" to agentSessionId to the file.attach params, and
refresh the sendMessage flow comment (no longer fire-and-forget).

Verified by source: both image.attach_bytes and file.attach call the
same _sess resolver. Camera capture is unaffected (it produces an
image/jpeg attachment, which already routes through the fixed
image.attach_bytes branch).

ChatViewModel.kt passes ktlintCheck.

* test(#587): regression for image.attach_bytes session_id

Adds testSendMessage_imageAttachment_sendsSessionId, which proves the
image-attach RPC is issued with session_id (the missing field that
caused JsonRpcError 4001 'session not found').

Root blocker found: readContentUriBase64 constructs
android.util.Base64OutputStream, which throws MockK's 'Stub!' on the
JVM test, making the read return null and the image branch never run.
Mocking the Base64OutputStream constructor lets the read succeed so the
request(any(), capture(paramsSlot), any()) stub captures the params and
the session_id assertion holds.

Full ChatViewModelTest suite passes; ktlint-clean.

---------

Co-authored-by: M57 <m57@hermes-control.app>
2026-07-13 22:40:16 +03:00
M57andM57 e48778291a fix: clarify Dismiss now informs the agent instead of silently dropping (#580)
* fix(#572): clarify Dismiss now informs agent instead of silently dropping

* fix(#572): clarify Dismiss rejects the question (no answer) instead of telling agent to proceed

---------

Co-authored-by: M57 <m57@hermes-control.app>
2026-07-13 18:53:09 +03:00