* chore(ui): replace emoji with Material icons across chat UI
Removes emoji glyphs and swaps them for real Material icons / plain text:
- ToolSchemaRegistry: iconEmoji String -> ImageVector icon for all ~35 tools;
summaryPrefix emoji stripped (terminal keeps '$ '); consumer renders Icon().
- ToolBubble: collapsed summary now shows Icon(config.icon) instead of an emoji
Text prefix; web-search url drops the link emoji; warning badge uses
Icons.Filled.Warning instead of a '⚠' Text glyph.
- ChatBubble SelfImprovementReviewCard: 🛠/🧠 String -> Build/Psychology Icon.
- ChatViewModel / ChatWsEventReducer: drop ⚠️/✅ prefixes from user-facing
status/error messages (severity already conveyed by color/styling).
- SubagentInspectionSheet: ✅/🔄/❌/⭕ status markers -> CheckCircle/Autorenew/
Cancel/RadioButtonUnchecked (tinted); ElectricBolt section headers ->
FormatListBulleted + Groups.
- strings.xml (en/zh/ko): drop '✓' from the Copied label.
ktlint clean; compileDebugKotlin SUCCESSFUL. Remaining chat files
(ToolViewBuilder, ChatComposer, MessageCards, ChatReactions, ComposerToolbar,
FullBleedAgentMessage) tracked in follow-up commits on this branch.
* test: align unit tests with emoji-free summary + status messages
composeToolSummaryLines dropped the emoji parameter and no longer prepends
an emoji glyph to the collapsed summary line, so update ToolSummaryLinesTest
call sites + expected strings accordingly.
SlashCommandDispatchRpcTest asserted the old '⚠️ /cmd: ...' error messages;
those prefixes were removed in the UI cleanup, so drop them from the
expected strings.
ChatWsEventReducerTest emoji usages are model-output fixtures, not assertions
on changed code paths — left untouched.
* feat(chat): compact sticky task progress chip (#942)
Re-add a glanceable progress strip above the chat timeline while work is
active. Distinct from the inline TodoTaskCard (permanent timeline record):
this chip is the temporary "what is happening right now" surface.
- Shows only while todos are incomplete or a subagent is running.
- Bounds to the same hydrated state.todos / subagentIndicators the card
uses -- NOT the old broken state.todos gate removed in #811.
- Label: "Tasks 2/5 · <in-progress task> · N agent" (agent count when relevant).
- Tap opens SubagentInspectionSheet + resumes bottom-follow.
- Auto-hides when all work completes/cancels; rebuilds on session resume.
- i18n strings added (en/zh/ko); ktlint clean; compileDebugKotlin + unit
tests green.
* refactor(chat): drop inline TodoTaskCard, keep single sticky progress chip
Per follow-up to #942: the inline todo card in the chat view is removed. The
sticky TaskProgressChip above the timeline is now the only glanceable surface;
the tool message itself remains the permanent record.
- Remove inline TodoTaskCard early-return in ToolBubble.kt (todo tool output
now renders like any other tool bubble).
- Delete TodoTaskCard.kt + TodoTaskCardTest.kt (SecurityRiskChip lives in
ToolBubble.kt, so risk display is unaffected).
- Redesign chip: replace the ElectricBolt icon with a real
CircularProgressIndicator (no emoji), keep ChevronRight affordance.
- Label handles the no-todos case (subagents only): 'N agent running'.
- Drop dead todo_task_count string; add task_progress_agent_running (en/zh/ko).
ktlint clean; compileDebugKotlin + chat unit tests green.
* chore: clear pre-existing compile warnings
- HermesWsClient.kt: drop unnecessary safe call on non-null ResponseBody
(resp.body.string()) and the now-dead 'body != null' guard it implied.
- SessionsScreen.kt: replace deprecated Icons.Filled.CallSplit with the
AutoMirrored variant (match existing Send usage in this file).
ktlint clean; compileDebugKotlin SUCCESSFUL with no warnings.
* chore(chat): replace emoji status markers with Material icons in inspection sheet
The sticky progress chip opens SubagentInspectionSheet on tap; that sheet used
emoji Text glyphs for status. Replaced with real Material icons:
- Todo rows: completed -> CheckCircle (success), in-progress -> Autorenew
(primary), cancelled -> Cancel (warning), pending -> RadioButtonUnchecked
(muted).
- Subagent rows: complete -> CheckCircle (success), failed -> Cancel (error),
running -> Autorenew (primary).
- Section headers: ElectricBolt -> FormatListBulleted (agent plan) + Groups
(subagents).
Icons tinted via LocalHermesStatusColors. No emoji remain in the chip's tap
surface. ktlint clean; compileDebugKotlin SUCCESSFUL.
* fix(lint): drop orphaned todo_task_count translation in ko
Removing the string from the default (en) locale left a stale Korean
translation, which tripped Lint's ExtraTranslation error and aborted
lintDebug. Removing the orphan so default + translations stay in sync.
android-lint now BUILD SUCCESSFUL (0 errors).
* fix(chat): address #943 review - progress count, resume hydration, chip tests, plurals
- TaskProgressChip: current-task number is now the first unfinished,
non-cancelled todo's 1-based position (active index + 1), so a completed
task 1 + in-progress task 2 reads as 2/5 instead of 1/5 (review point 2).
- Deterministic resume hydration: hydrate todos directly from cached Room
messages and from the merged REST transcript inside ChatViewModel load
paths, in addition to the WS reducer, so a resumed session rebuilds the
chip without waiting for a follow-up event (review point 3).
- Extracted computeChipDisplay / shouldShowProgressChip pure functions and
added TaskProgressChipTest covering active-task progress, completed/cancelled
hide, subagent-only state, and transcript-resume hydration (review point 4).
- agent count strings are now Android plurals (task_progress_agents /
task_progress_agent_running) in en/zh/ko to avoid '2 agent' (review point 5).
Co-Authored-By trailer intentionally omitted per repo convention.
* fix(chat): derive chip number and content from the same selected task
computeChipDisplay selected the task number from the first unfinished/
non-cancelled todo but the content independently from the first in_progress
todo. With a pending task 1 + in_progress task 2 this produced 'Tasks 1/N ·
<task2 content>'. Now both values come from one selected item (prefer
in_progress, fall back to first pending/non-terminal).
Adds two focused tests: same-item selection, and pending-only fallback.
- ws: ticket-mint response body was read on the calling (main) thread —
the socket read threw NetworkOnMainThreadException, the mint failed,
and the kanban events stream never connected (board stuck Offline).
execute() + body read now run entirely on Dispatchers.IO; same fix in
DashboardSessionTokenRefresher.fetch (loopback mint path).
- kanban: re-selecting the already-active board reloads data and
reconnects events WITHOUT the redundant POST /boards/{id}/switch that
refresh fired on every tap.
- plugins: top-bar Refresh + pull-to-refresh wired through HermesScaffold
(previously no reload control at all).
- skills: Refresh icon now does a plain GET reload; the heavy hub-update
POST moved to the cloud icon so refresh is no longer disguised as update.
- test: mock the 3-arg Log.w overload (catch now logs the full stack).
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(chat): /queue submits with queued=true so the backend queues instead of redirecting
The mobile app routed /queue through command.dispatch, whose backend shim
only echoes the text back as a plain send payload. The re-submitted
prompt.submit carried no queued flag, so a busy session applied the
default busy_input_mode (interrupt) and REDIRECTED the live turn instead
of queueing — verified on-device: backend replied {"status":"redirected"}
and the queued message was dropped, never answered.
Intercept /queue and /q client-side and submit prompt.submit directly
with queued=true, which the gateway honors as run-after-never-interrupt
regardless of display.busy_input_mode (hermes-agent methods_prompt.py
_handle_busy_submit). Verified on the emulator: {"status":"queued"},
the running turn completed untouched, and the queued prompt ran as the
next turn and was answered.
* fix(chat): /queue bubble shows the queued text so the sync dedupes the echo
The optimistic bubble for /queue carried the raw command ("/queue foo"),
but the server only ever sees the stripped text ("foo") — the transcript
sync's logical dedupe (sameLogicalMessage) can't match them, so the echoed
user row survived as a duplicate that rendered BELOW its own answer.
QueuePrompt now carries the display content: the stripped queued text (or
the raw command when bare). handleSlashCommand classifies first and uses it
for the optimistic bubble, so local and REST copies match verbatim and the
merge collapses them into one row at the correct position.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* chore: eliminate all Kotlin compiler warnings in main source
* test(chat): fix cross-class coroutine leaks poisoning the unit suite
- ChatUpdateCommandTest: inject FakeSlashUsageStore — the real DataStore
store inits on real IO threads with a relaxed-mock app (cacheDir NPE)
and leaks an uncaught exception into the next runTest class.
- ChatUpdateCommandTest + SlashCommandDispatchRpcTest: mock
ProfileSwitchCoordinator.switched (ChatViewModelTest pattern) — VMs
otherwise park collectors on the singleton flow that crash later
classes (ProfileSwitchCoordinatorTest) on stale-Main resumption.
Full suite: 944 tests, 0 failures (was 2 flaky per run).
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* test(connect): pin restart-with-scoped-profile token resolution (phase 5)
* fix(profiles): scope switch via un-clamped active-profile state
The switch persisted the server profile through serverStore.selectedProfileId,
whose selfHealed() clamps ids absent from the local connection list — server
profiles like "meow" never exist there, so the scope silently reset to
default and the re-dialed socket created the fresh session in the wrong
profile (live logcat 2026-08-06: session.create carried profile=default
right after switching to meow).
- AuthManager: prefs-backed activeProfileId state, immune to the clamp,
restored on startup; resetAuthStateForTest clears it
- ProfileScopeInterceptor + WsProfileParams + ChatViewModel fetchContextUsage:
read the active profile instead of the connection selection
- ProfileSwitchCoordinator: write activeProfileId; dial the socket on
Dispatchers.IO (the ticket mint did blocking I/O on the main thread —
NetworkOnMainThreadException — and fell back to a 1s reconnect)
- ProfilesViewModel: inject the IO dispatcher; ProfilesViewModelTest drops
its static Dispatchers mock (JVM-wide bleed class)
- Tests: WsProfileParams/Interceptor tests use the real prefs-backed state
instead of mockkObject(AuthManager) (re-mock breaks in suite order after
real AuthManager init); AuthManagerTest + Issue647ProfileUrlTest cancel
the init() DataStore scope in tearDown (UncaughtExceptionsBeforeTest
phantoms); regression test pins the clamp on the old path vs the new state
* fix(profiles): resolve getter/property JVM collision; de-poison slash test
- AuthManager.activeProfileId (StateFlow) and a getActiveProfileId()
function generated the same JVM getter name — calls silently resolved to
the property getter at runtime (ReadonlyStateFlow -> String
ClassCastException, CI unit red 2026-08-06). Function removed; callers
read activeProfileId.value.
- ChatViewModel: inject ioDispatcher (38 sites) so SlashCommandDispatchRpcTest
can drop its mockkStatic(Dispatchers) bleed without racing real IO.
- SlashCommandDispatchRpcTest: drop the static Dispatchers mock (JVM-wide
bleed; CI cross-wired COMMAND_DISPATCH captures in suite order), construct
the VM with the test dispatcher.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(profiles): make profile switching actually scope the app (issue #781 follow-up)
* fix(profiles): inherit connection token when switching to same-dashboard profile (issue #781 follow-up)
* fix(chat): reset open session on profile switch + prefill login profile (desktop parity)
* fix(ws): inject profile into profile-scoped RPC params so chat follows the selected profile
* test(chat): de-poison SlashCommandDispatchRpcTest (drop static Dispatchers mock)
mockkStatic(Dispatchers::class) + a stubbed Dispatchers.IO bleeds JVM-wide and
breaks later classes in suite order (CI 2026-08-06: COMMAND_DISPATCH captures
cross-wired → ComparisonFailure on lower stack PRs). ChatViewModel gains an
injected ioDispatcher (38 sites); the slash test constructs the VM with the
test dispatcher so the IO stub is unnecessary.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
The chat notification FGS — and its mandatory persistent 'Waiting for
Hermes replies' notification — was started on every ON_STOP of the chat
screen, even when nothing was in flight, and lingered at app startup.
Track a pending-reply flag on HermesWsClient (set when a prompt is
submitted and on any in-flight turn event, cleared on MessageComplete),
gate NotificationHelper.start() on it, and have the service retire
itself after delivering the reply notification in the background.
Replies sent from the notification re-arm the service for the
follow-up turn.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(kanban): live board updates over plugin events WebSocket (#775)
Tail /api/plugins/kanban/events per board with cursor tracking and
backoff reconnect; debounced REST reload on event batches (desktop's
proven pattern — event payloads are partial updates). Board switch
re-subscribes with a fresh stream. Reuses HermesWsClient's ticket
refresh (now internal). Adds ServerEndpoint.webSocketUrl(path, ...)
overload for non-chat WS endpoints.
Kanban UI pass: add-task FAB (the AddTaskDialog was unreachable),
live/offline status pill, assignee chip on task cards, 2-line
description clamp, empty-column hint.
Tests: KanbanEventsClient frame parsing, KanbanViewModel WS wiring
(connect on load, debounced reload, board-switch reconnect,
no-reconnect guard, onCleared), ServerEndpoint overload.
* fix(kanban): create task response is wrapped {"task": ...} — read it as Unit
Backend POST /api/plugins/kanban/tasks returns {"task": {...}} (wrapped),
but the app declared Response<KanbanTask> — with ignoreUnknownKeys=true
the wrapper key is skipped and the required id/title/status fields are
missing, so deserialization threw and the create showed a failure toast
while the task WAS created server-side (ghost tasks). The body is never
consumed by the ViewModel, so Response<Unit> matches updateKanbanTask.
FAB from the live-events PR (#775) made this latent bug reachable.
Adds an E2e regression assertion for the create path.
* feat(kanban): remove move left/right arrows — backend rejects the transitions
The dashboard PATCH route's status whitelist omits 'review' (a first-class
kanban_db status), so any move touching that column 400s, and reopen
transitions (done->blocked) 409. Every task on the live board sits in
'done', making the arrows 100% failure. Removing them keeps the UI honest;
tasks are managed from the desktop/CLI.
Drops moveTask/revertTaskMove from the VM, the arrows from TaskCard, the
now-dead updateKanbanTask API method and move strings; E2e test keeps
load/create coverage.
* feat(kanban): desktop-style task transitions — gated action sheet
Mirrors the desktop kanban's move model: tap a card to open an action
sheet with only the transitions the backend PATCH route accepts:
- triage / ready from any status (direct write; unblock from blocked)
- block only from running/ready, complete only from running/ready/blocked
- archive from any non-archived status
- never running (dispatcher-only, backend 400s) and never review (not in
the dashboard whitelist) — the moves that made the old arrows lie
Complete prompts for a mandatory completion summary (stored as result),
matching the desktop; block/complete/archive ask for confirmation.
Optimistic update with revert + toast on failure.
Tests: transition-gating map, move PATCH body, failure revert, completion
summary body; E2e kanban test covers a desktop-style todo->ready move.
* ui(kanban): replace oversized FAB with compact top-bar add button
The 56dp FAB floated over the board's bottom-right corner and covered a
chunk of the last column. The add action now lives in the header row next
to the live pill (input-top style), and the board's bottom contentPadding
drops back to 16dp — no more reserved dead zone.
* fix(kanban): mint an independent WS ticket — don't touch the chat's shared slot
The dashboard WS ticket is single-use and consumed at handshake. Both the
chat client and the kanban events client were refreshing into the SAME
shared AuthManager token slot on their own schedules (the kanban client on
every backoff reconnect, forever). Whichever client built its URL second
grabbed the other's just-consumed ticket, got 1008, reconnected, and stole
the other's next ticket — mutual starvation that left the app stuck at
'Reconnecting...' (repro on main: none, because main has one consumer).
HermesWsClient now exposes mintWsTicket(): a ticket minted WITHOUT touching
the shared slot (cookie-auth'd, same endpoint). The kanban events client
uses its own ticket per connection; the chat WS keeps the shared slot.
Refresh's HTTP-error -> status mapping is preserved via a small result
holder.
* fix(kanban): drop orphaned ko translations for removed move buttons
The move arrows were removed earlier but values-ko still shipped
content_desc_move_left/right — Android lint ExtraTranslation failed CI.
* fix(ws): keep RECONNECTING on transient ticket-mint exceptions
My refactor flattened the ticket-mint failure mapping: a network exception
during POST /api/auth/ws-ticket was reported as a null HTTP code and
mapped to DISCONNECTED, breaking the #740 contract that transient failures
stay in RECONNECTING (and with it testGatedMode_transientTicketFailure-
KeepsReconnectFlow). TicketRequestResult now carries an exception flag and
refreshWsTicketIfNeeded maps it to RECONNECTING.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(chat): preserve reasoning across tool calls + REST reload (#771)
A reasoning-model turn streams its thinking BEFORE the tool starts. The
reducer wiped the streaming state at tool.start (returned a fresh
StreamingState()), so the reasoning bubble vanished mid-turn and the
finalized answer had no reasoning card.
Real gateway capture for `run echo hi` (reasoning model):
message.start
reasoning.delta xN (thinking before the tool)
tool.generating/start/complete (terminal)
message.delta xN (answer after the tool)
reasoning.available (FULL trace in payload text)
message.complete (text + reasoning field)
Fixes:
- onToolStart keeps the streaming message + reasoningText alive across
the tool call (only buffers are cleared via clearStreamingBuffers);
an interim-text orphan still seals, but reasoning survives for the
post-tool answer
- EventParser now reads reasoning.available text and message.complete
reasoning (the authoritative full trace the gateway sends)
- onReasoningAvailable fills the streaming message with the full trace
- onMessageComplete prefers the payload reasoning over delta leftovers
- mapServerMessages folds reasoning-only empty assistant rows into the
next real answer bubble (no more standalone reasoning ghost bubble)
and carries tool names over from the live WS list by position (no
more generic 'tool' title after a REST reload)
Regression tests replay the captured sequence end-to-end.
* fix(chat): dedupe WS + REST tool bubbles by result content (#771)
The REST transcript tool rows carry no tool name, and WS messages (UUID
ids) + REST rows (rest- ids) are BOTH persisted to Room — the cache
paint and reloads could render the same tool call twice, with the
second bubble falling back to the generic 'tool' title.
Real captured pair for 'echo meow':
terminal {tool_id, name, args, result:{...}} (WS)
Tool {output, exit_code, error} (REST, no name)
The previous positional carry-over misaligned whenever the REST page
and the live tool list didn't line up 1:1 (multiple tool calls in
history → newest call got null name).
Fixes:
- canonicalToolResultKey(): content-based matching between the WS
tool.complete payload and the REST result row — int/float agnostic
(0 vs 0.0), prefers the payload's 'result' field
- mapServerMessages(): when a REST tool row's result matches a live WS
tool message, REUSE the live message wholesale (same id + toolName +
rich payload) so Room upserts instead of accumulating a rest- copy
- dedupeCachedMessages(): cache paint drops rest- copies whenever a WS
copy of the same logical message exists (tools matched by result,
other roles by content)
11 new unit tests with the real captured payloads.
* fix(chat): merge transcript reload with live WS bubbles (#771)
The gateway persists a tool row only when the tool COMPLETES server-side.
A REST transcript reload landing mid-turn (app background/foreground,
reconnect re-resume, pull-refresh) therefore returned a page WITHOUT the
in-flight tool row — replacing the message list outright made the tool
bubble vanish and left tool.complete with no RUNNING message to update.
- mergeTranscriptWithLive(): reload now merges instead of replacing —
any current message the REST page doesn't cover (by id or logical
content) survives, chronological order preserved
- loadSessionMessages applies the merge before publishing state
4 new unit tests: in-flight RUNNING tool survives a mid-turn reload,
reused-id and canonical-match cases don't duplicate, order preserved.
* test(chat): end-to-end tool-bubble survival through mid-turn reload
Chains the real user scenario at the state level: WS turn (message.start →
reasoning → tool.start → tool.complete) + a transcript reload landing
while the tool is still running server-side (REST page lacks the tool
row) + stream finish. Asserts the tool bubble survives to the final
list with its name and status intact.
* debug(chat): add Logcat tracing for tool-bubble vanish (#771)
Temporary diagnostic logging to catch the exact moment and code path
where the tool bubble disappears. Logs to tag 'ChatReducer771' and
'ChatVM771' at every state mutation that touches messages:
- onMessageComplete: message count + roles before the assistant append
- loadSessionMessages: rest vs current vs merged counts and roles
- syncCurrentSession: trigger + current tool count
- sync merge: before/after merge counts and roles
To reproduce: install this debug APK, run 'echo meow', watch the tool
bubble vanish, then capture logcat:
adb logcat -d -s ChatReducer771:D ChatVM771:D
Will remove the logging once the root cause is pinned.
* fix(chat): drop debug logging + harden sync merge (#771)
The debug commit added android.util.Log calls in the reducer's
onMessageComplete path, which is a Stub! in JVM unit tests — every
test touching that path died with RuntimeException, and the long
log lines broke ktlint (max-line-length). Remove them.
Sync merge fix: the 5s sync poll could drop the newest WS tool
bubble when the REST page lagged one row behind. Route the merge
through sameLogicalMessage (canonical result-key match) and keep
any WS message that has no REST counterpart.
Adds 2 regression tests for the sync-merge scenario.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* 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>
* 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>
- 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>
* [feature] issue #634: HTTPS + reverse-proxy server endpoint support
- Add ServerEndpoint model (scheme-aware base URL, resolve/webSocketUrl/relativeRequestPath, cleartext policy)
- AuthManager: baseUrl is canonical; setHost/setPort derive from endpoint; endpointForBuild() for scheme-aware URLs
- ApiClient/CookieManager/HermesWsClient: route through ServerEndpoint (no hardcoded http://)
- Data migration: ServerUrlMigration converts legacy host/port to baseUrl
- UI: Connect/AuthLogin/Settings now use a single Server URL field with transport warning
- Build: ALLOW_CLEARTEXT buildConfigField + manifest placeholder for dev
- Tests: ServerEndpointTest + ServerUrlMigrationTest; adapt existing suite to baseUrl API
Co-Authored-By: M57 <Hy4ri@users.noreply.github.com>
* fix(authlogin): basic-auth derives token automatically, no manual entry
connectBasicAuth() built a fresh OkHttpClient for the password-login call and
a different one for the ws-ticket call, so the Set-Cookie from
/auth/password-login (the session token lives in the cookie, not the JSON body)
never reached /api/auth/ws-ticket. That 401'd the ticket request, returned null,
and the UI fell back to asking the user to paste a token.
Both calls now use the shared OkHttpProvider.probe client (same CookieManager
jar), so the session cookie flows through and the WS ticket mints automatically —
matching the dashboard contract (routes.py: password-login sets Set-Cookie,
ws-ticket requires it).
Add AuthLoginBasicAuthFlowTest proving the cookie propagates on the shared
client (and that ws-ticket 401s without it).
Fixes the basic-auth token prompt reported against issue #634.
* chore: remove TASK-634-TESTS.md (stray task-tracking doc, not part of feature)
* ci: broaden .gitguardian.yml test-file ignores (unit + instrumented + variant dirs)
* feat(authlogin): smart auth-mode detection from /api/status + drop pairing-string login
- Replace 302-sniff probe with authoritative GET /api/status read
(auth_required + auth_providers) to derive DashboardAuthMode.
- Add OAUTH mode (stubbed, 'coming soon' UI, TODO tracks issue #639).
- Render login UI by detected mode: token (loopback), username+password
(basic/gated), or OAuth notice.
- Remove legacy pairing-string box + onPairingString/applyLegacyPairing.
- Extract internal deriveAuthMode() + add 5 unit tests.
Closes nothing; OAuth tracked in #639.
* fix(chat): don't block WS connect on empty token in gated (ticket) mode
connectWebSocket() gated on getToken() != null, correct for loopback
(token) mode but wrong for gated (ticket) mode: the ticket is minted
fresh by HermesWsClient.refreshWsTicketIfNeeded() from the persisted
session cookie, so getToken() is expected empty at connect time. The
early return left the WS unopened -> DISCONNECTED -> chat showed
'reconnect' immediately after basic-auth login.
Only require a non-blank token in non-gated mode.
Fixes#640.
* test: remove onPairingString test cases for deleted pairing-login path
The pairing-string login (onPairingString/applyLegacyPairing) was removed
from ConnectViewModel, so the 5 tests that exercised it (3 in
ConnectViewModelTest, 2 in E2eIntegrationTest) no longer compile. Remove
them. Also drop the now-orphaned connect_pairing_string from values-ko.
Fixes the Unit & Integration Tests CI failure on PR #638.
---------
Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
* feat(billing): adopt subscription.* + usage.bars WS methods (issue #628)
Add Billing/Plan screen + WS client surface for the backend release audit
(hermes-agent 0bf44d557..614dc194e). credits.view was removed upstream;
subscription.{state,preview,change,resume,upgrade} + usage.bars replace it.
- WsMethods: add SUBSCRIPTION_* + USAGE_BARS constants
- BillingModels: @Serializable request/response (snake_case, ignoreUnknownKeys)
- BillingRepository: WS dispatch via HermesWsClient.request + typed decode;
HermesRpcException (-32601) surfaces as featureUnavailable, not a crash
- BillingViewModel: plan/usage load + upgrade/change/resume/cancel actions
- BillingScreen: HermesScaffold composable (plan card, usage bars, actions)
- NavigationKeys + ScreenRegistry + strings (en/ko) wire BillingScreen into
the INSPECT drawer section
Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.
* fix(billing): align models + UI to live gateway wire shape (issue #628)
Live-probed the 6 subscription.* / usage.bars RPCs against the running
backend (hermes-agent dashboard :9119, 2026-07-19). Findings:
- subscription.state returns logged_in / current / tiers / usage (nested),
NOT the legacy subscription_type_id / renews_at fields the models assumed.
- Mutating RPCs (preview/change/resume/upgrade) return ok:false *results*
with error/message/payload (e.g. invalid_token when not portal-authed),
not RPC-level errors — so the ViewModel's HermesRpcException guard never
fired and the screen silently half-decoded null fields.
- subscription.upgrade also returns an idempotency_key not in the model.
Changes:
- BillingModels: re-shape SubscriptionStateResponse around current/tiers/
usage; add ok/error/message/payload envelope + idempotency_key to the
action responses; add SubscriptionCurrent / SubscriptionTier / preview
payload types. All nullable + ignoreUnknownKeys (graceful degrade).
- BillingViewModel: detect ok:false results on every call and route to
featureUnavailable / errorMessage instead of swallowing them.
- BillingScreen: read plan from subscription.current; render a clean
'no active plan' / 'log in to Nous Portal' card on the logged-out path
instead of a broken null plan card.
- strings: add billing_no_active_plan / billing_login_required (en + ko).
Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.
* fix(billing): address independent review (agy) — error routing + icon
Triage of the independent AGY review on PR #629:
Fixed (real issues):
- BillingViewModel: usage.bars RPC failure (-32601) no longer marks the
whole feature unavailable (was blocking the plan card despite the
'non-fatal' comment); it is now correctly non-fatal.
- BillingViewModel: a failed primary subscription load now surfaces
errorMessage even when usage bars loaded (was swallowed by
'sub == null && bars == null').
- BillingScreen: render state.errorMessage as an error card inside
BillingContent so failed mutating actions (upgrade/cancel) are visible
instead of silently disappearing.
- BillingScreen: wrap the dead AccountBalanceWallet expression in a real
Icon() composable in FeatureUnavailableState.
Declined (with rationale, recorded for maintainer):
- Tier-select / upgrade UI (review issue #4): explicitly out of scope per
the PR body — 'Plan-selection UI ... is not built ... left for a
follow-up once backend returns the available plans.'
- Rename snake_case Kotlin props to camelCase (review nit #2): incorrect —
OkHttpProvider uses JsonNamingStrategy.SnakeCase, which maps snake_case
wire keys onto snake_case properties; the props are correct. Fixed the
misleading doc comment instead.
* fix(billing): match REAL logged-in wire shapes + kill null-result crash
The live gateway probe (logged-in Nous Portal session, 2026-07-19) showed
the earlier models assumed the WRONG shape:
- subscription.state.current is {tier_id, tier_name, monthly_credits,
credits_remaining, cycle_ends_at, pending_downgrade_*} — NOT the legacy
subscription_type_id/status/renews_at fields. Screen rendered 'Free / no
plan' for a real logged-in user.
- usage.bars is {available, status, plan_name, plan_bar{remaining_display,
total_display, pct_used, fill_fraction}, topup_bar} — NOT a List<UsageBar>.
- Mutating RPCs return error:'insufficient_scope' when the Portal token
lacks billing:manage scope (ok:false result, not an RPC error).
Crash fix (root cause of 'Billing RPC returned no result payload'):
- BillingRepository.decode() now returns null on a missing/null result
instead of requireNotNull() -> IllegalStateException. All repo methods
return nullable; ViewModel handles null as a non-fatal 'No billing data
returned' instead of crashing.
- BillingScreen.PlanCard / UsageBarRow rewritten to the real fields; the
resume/cancel branch now gates on can_change_plan (no status field exists
on current anymore).
- UsageBarsResponse + UsageBar reshaped to plan_bar/topup_bar; added
billing_credits_remaining / billing_plan / billing_topup strings (en+ko).
Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL. Decode sim
against the captured live responses confirms PlanCard/UsageBarRow/error
paths all resolve with no null-crash.
* fix(billing): decode WS result that arrives as toAny() Map, not JsonElement
Root cause of 'No billing data returned' on a logged-in server: the backend
returns a valid subscription.state result, but HermesWsClient.request()
yields result already converted via JsonElement.toAny() (a Map<String,Any?>),
not a raw JsonElement. BillingRepository.decode() only accepted JsonElement,
so was null and the repo returned null -> ViewModel showed
the generic 'No billing data returned' message. The server DOES have billing
(logged_in:true, tier_name:Free, /usr/bin/bash.10 credits) — the app just couldn't parse it.
Fix: decode() now normalizes whatever arrives — a kotlinx JsonElement OR the
toAny() Map/List — back into a JsonElement via a recursive Any->JsonElement
walk, then deserializes. Both shapes decode correctly now.
This also means the previous 'Billing RPC returned no result payload' crash
and the generic 'no data' message were both symptoms of this same parse gap,
not a missing backend feature.
Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* 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>
* 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>
* 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>
* 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>
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>
* 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>
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.
* 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.
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.
* 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
* 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>