System events ride as role=user rows with a display_kind sidecar (backend
issues #904/#82888). The full-bleed renderer routes any displayKind != null
to a SystemEvent, but timelineMarkerLabelRes only had labels for
model_switch / personality_switch / auto_continue / async_delegation_complete.
skill_invocation and internal_notification had NO label, so TimelineMarkerChip
returned early and they fell through to the bare SystemBubble (centered italic
text, no clear system identity) instead of a distinct chip.
- Add skill_invocation + internal_notification to timelineMarkerLabelRes.
- Add string resources en/zh/ko (ko was missing all timeline markers; added
the two new ones for parity).
- TimelineMarkerTest: assert both new kinds map to labels.
Result: every backend system message type now renders as its own centered
timeline chip, never as a user bubble. Legacy sessions persisted before the
backend added display_kind tagging still lack the sidecar and would need a
one-time backend re-tag to stop rendering as user bubbles.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Tapping a gateway attachment gave zero feedback while the file
downloaded (a 100MB file could sit there silently for tens of seconds).
Mirror the save spinner: ChatUiState gains openingAttachmentPath, set
when openAttachment starts the download and cleared in a finally, and
the attachment card renders a small CircularProgressIndicator while its
path is being fetched. Wired through FullBleedChatList, ChatBubble and
FullBleedAgentMessage.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix: stream gateway downloads to disk instead of slurping into RAM
Opening or saving a ~100MB MEDIA file OOMs the app: fetch() read the
whole response body into a single ByteArray (peak heap ~2x file size)
before writing it to cache. A 102MB docx (~97MiB) passed the server's
100MiB cap but blew the phone heap (192MiB growth limit) on both open
and save.
fetch() now streams the body to a cache file in 64KiB chunks and
GatewayFile carries the written file instead of bytes; save copies the
cache file to the destination chunked. Cancellation-safe (partial file
deleted, CancellationException never swallowed), UUID-named cache
files, 24h sweep.
Closes#913
* fix: stub app.cacheDir/applicationContext in attachment tests
The streaming fetch builds File(cacheDir, ...) from the application
context's cacheDir; the affected tests never stubbed it, so the mock
returned a bogus File (null path) and java.io.File's constructor NPE'd
before fetch was even called. Stub cacheDir + applicationContext
(returning app itself, matching the existing success-path test) in the
five open/save tests. copyChunked is also stubbed to copy for real in
the save-writes test since mockkObject mocks the whole object.
---------
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>
* fix(chat): snappy in-chat search over visible text only
- findMatches now scans USER + ASSISTANT messages only — tool rows and
system events were matched before, so navigation jumped to invisible
matches and the scan paid for the chat's heaviest strings (tool payloads)
- Reasoning text is explicitly out of scope (lives in reasoningText, never
highlighted) — documented in the controller contract
- Debounce (150ms) coalesces typing into one scan instead of one full scan
per keystroke; blanking the query still clears instantly
- Tests: controller scope (user/assistant only, tool/system/reasoning
excluded, case-insensitive, navigation wrap) + delegate debounce
(coalescing, cancelled intermediate queries, instant blank clear)
* fix(chat): search navigation lands the match mid-view, not at an edge
scrollToSearchMatch top-aligned the matched message with
animateScrollToItem(index), pinning it to the viewport edge — tall
messages left the highlighted word above/below the visible area.
Now top-aligns, then nudges the item down to ~1/3 viewport height
(verified top-align -> measure -> adjust pattern, same as scrollToBottom).
* fix(chat): search scroll targets the real lazy item and the actual word
ROOT CAUSE: search-match indices are MESSAGE-list indices, but the
LazyColumn has extra items (reasoning hoists, tool rows, loading-older),
so scrollToSearchMatch(index) landed on the WRONG item whenever reasoning
or tools preceded the match — the 'does nothing / word under or above the
view' bug from the start.
- messageIdToLazyIndex(): pure messageId -> LazyColumn item index mapping
mirroring FullBleedChatList's emission order (verified in tests)
- search-scroll effect moved INTO FullBleedChatList (the only composable
that knows the mapping), serialized through the same ChatScrollController
- word-level targeting: each SearchMatch now carries its character offset
in the message; the scroll places offset/contentLength x item height at
~1/3 viewport, so the highlighted word itself lands in view on tall or
multi-occurrence messages
- counter/navigation now count OCCURRENCES (word hits), one entry per hit
with a parallel searchMatchOffsets list; per-message dedupe removed
- tests: lazy-index mapping (reasoning hoist, tools, leading items),
SearchMatch offsets, occurrence flattening, regex-literal queries
* fix(ci): pass searchMatchOffsets and scrollController to FullBleedChatList in instrumented test
The render helper in FullBleedChatListTest still used the pre-offset
signature, so compileDebugAndroidTestKotlin failed on the emulator job.
Verified locally with compileDebugAndroidTestKotlin + ktlintCheck.
* perf(chat): allocation-free indexOf search matcher with capped results
- findMatches scans with String.indexOf(ignoreCase=true) on the ORIGINAL
content instead of Regex(Pattern.quote()).findAll: queries are always
literal, so regex was pure overhead — a compiled Pattern plus one
MatchResult allocation per hit. Offsets stay exact (no lowercase
transform, which can change string length on some scripts).
- returns SearchResult(matches, totalMatches, capped): results are capped
at 500 stored entries (MAX_SEARCH_MATCHES) to bound allocations and
navigation on degenerate queries, while the counter still shows the
exact total (searchMatchTotal/searchMatchCapped in ChatUiState).
- 14 controller tests green, delegate behavior unchanged.
* perf(chat): O(1) current-match lookup and highlight only matched bubbles
Two recomposition hot spots killed in the list renderer:
- isCurrentMatchFor() did messages.indexOfFirst per rendered bubble per
search update — O(n) per item, O(n^2) per update on the main thread.
Now currentMatchMessageId() resolves the current match's message id
ONCE per recomposition and items compare ids (O(1)).
- every prose bubble re-ran its highlight scan on every search-state
change even with zero hits. matchedMessageIds() computes the matched
id set once; bubbles outside it get an empty searchQuery and skip the
scan entirely.
Both helpers are pure, tested in FullBleedTurnsTest (18 tests), and the
instrumented test target still compiles.
* perf(chat): dedicated ChatSearchState holder — search no longer recomposes the list
Search fields lived in ChatUiState, so every debounced scan result changed
the whole state object: ChatScreen recomposed, FullBleedChatList's params
changed, and the entire list body re-ran (turns loop + item registrations)
on the main thread per search update.
Search state now lives in a @Stable snapshot-backed ChatSearchState
(LazyListState pattern) owned by ChatSearchDelegate:
- the list takes ONE stable param; the instance never changes, so the
body is skipped on search updates — only the scopes that read a field
recompose (search bar, matched bubbles, the scroll effect)
- matchedIds and currentMatchId are computed once per scan/navigate in
the delegate and stored as state fields — items do O(1) id lookups
inside their own lambdas, no body-level derivation
- ChatUiState loses the 7 search fields; ChatViewModel exposes
viewModel.searchState; delegate tests now assert on the holder
19 search-related unit tests green, androidTest compiles.
* test(chat): long-session search sanity (2000 msgs) — exact counts, valid offsets
Synthetic 2000-message / ~1KB session verifying the matcher at scale:
- frequent word: exact 32_000 total, capped storage, every stored offset
points at a real occurrence
- unique word resolves to the right message; tool payloads stay excluded
- local measurement: full scan ~7ms (regex version allocates per hit)
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Deletes the ChatMessageList/ChatStreaming bubble path and the ChatStyle
setting (issue #866 phase 6). ChatScreen now renders FullBleedChatList
directly; ChatBubble becomes the user-message anchor with the shared
SystemBubble/InlineAttachment/formatTimestamp helpers intact. Stubs of
AuthManager.getChatStyle() removed from four test suites; TodoTaskCardTest
renders ToolBubble directly.
Verified: ktlintCheck green, testDebugUnitTest 962/962 pass, assembleDebug
APK builds.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(chat): ChatStyle preference type + store field + AuthManager (issue #866)
* feat(settings): chat style toggle in Appearance (issue #866)
* feat(chat): plumb ChatStyle into ChatUiState + refreshSettings (issue #866)
* feat(chat): full-bleed turn grouping (issue #866)
* feat(chat): full-bleed agent message renderer (issue #866)
* feat(chat): full-bleed compact tool rows + system events (issue #866)
* feat(chat): full-bleed chat list renderer + streaming (issue #866)
* feat(chat): wire full-bleed renderer behind chat style setting (issue #866)
* test(theme): full-bleed contrast gate across all presets (issue #866)
Gate caught NORD light primary/background < 3:1 — header role label
switched to onSurfaceVariant (meta token, correct for header + passes
in every preset; NORD's pastel Frost accent can't reach 3:1 on light).
* test(chat): full-bleed renderer coverage (issue #866)
* style(chat): ktlint formatting pass on full-bleed renderer (issue #866)
* fix(chat): eagerly capture per-item state in full-bleed list (issue #866)
LazyColumn item{} lambdas run at composition time, so loop locals read
inside them saw final values: turn header never rendered (firstProseSeen
always true at compose) and milestones misindexed. Capture showTurnHeader
+ milestone as immutable vals during the DSL loop. Caught by fresh-context
review (decompiled Compose foundation to confirm item composition timing).
* test(chat): stub AuthManager.getChatStyle in mockkObject setups (issue #866)
refreshSettings()/loadSettings() now read AuthManager.getChatStyle();
the four test files that mockkObject(AuthManager) with explicit stubs
had no stub for the new method, so mockk fell through to the real
object and threw 'AuthManager not initialized'. Full suite: 953/953
passing, same shape as pristine origin/main.
* feat(settings): move chat style toggle from Appearance to Chat section
* feat(chat): hoist reasoning above tool rows in full-bleed turns
Tool rows stream in before the answer message, so the turn rendered
tools above the reasoning card. The turn's reasoning now renders at
the top of the agent turn, with tool rows and prose underneath.
Streaming is folded into the turn model (groupIntoTurnsWithStreaming)
so the hoist works live while the answer is still streaming, and the
detached streaming item is gone (which also fixed the duplicate
turn-header quirk for streaming continuations).
* feat(chat): cap reasoning card height + long-press to copy
Expanded trace caps at 40% of the screen with internal scroll (Show
full lifts the cap); long-press anywhere copies the reasoning.
* feat(chat): blinking typing caret while streaming
* feat(chat): make full-bleed the default chat style
* feat(chat): remove thinking dots, show agent header above reasoning
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* 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>
Backend now supports order=latest on GET /api/sessions/{id}/messages
(offset measured back from the newest message, page returned
chronologically). The mobile initial load previously computed its anchor
from a possibly-stale session-list message_count, which landed the first
page at the wrong position (dropped history or a duplicated tail).
- HermesApiService.getSessionMessages: add @Query("order")
- Initial load requests the newest page directly (order=latest, offset=0)
and drops the fetchServerMessageCount offset math; legacy backends
(no pagination echo) fall back to the count-based anchor
- Backward paging advances the from-end offset by a full page; a short
page marks the oldest boundary and stops pagination
- ChatMessage stable keys under latest paging use the server's
AUTOINCREMENT row id (from-end positions shift as the transcript
grows and would collide across hydrations)
- syncCurrentSession refetches the newest page and merges by row id
with a logical-content fallback for re-keyed rows
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(chat): stop duplicating interim commentary before tool calls (issue #842)
Interim commentary the agent narrates before each tool call rendered
TWICE. Two mechanisms, both now covered:
1. The old tool.start orphan seal + message.complete's full text: when the
complete payload repeats the commentary, the final bubble duplicated
it. onMessageComplete now strips the sealed-orphan prefix (tracked in
StreamingState) from the final text.
2. On-device capture + DB forensics (2026-08-08) exposed the real
repeat offender: the app seals the RAW streamed text — which carries
leading blank lines the model emits before its narration — while the
backend persists a CLEANED copy (leading whitespace stripped). A
transcript reload ~10s later merged the REST page, exact-content
matching failed, and the cleaned REST copy rendered on top of the
live bubble. sameLogicalMessage now compares trimmed content, so the
sealed live bubble is covered by the REST row.
Also:
- session.interrupt seals the in-flight streaming message before
clearing streaming state, so interrupting a commentary turn keeps the
partial text
- regression tests cover both turn shapes (commentary in the final row →
stripped; absent → kept) and the whitespace-drift reload case
* fix(chat): match tool rows by gateway call id, drop reasoning-echo dupes (#842)
REST transcript rows carry tool_call_id; live WS bubbles keep tool_id from
tool.start. Match tool rows on that 1:1 identity so MCP/web rows (stored as
raw <untrusted_tool_result> text with no JSON key) dedupe during the
post-turn transcript merge instead of ghosting after the final message.
reasoning.available echo (narration text == message body, sent ~40ms before
each tool.start) no longer overwrites the real reasoning trace, so narration
bubbles don't render their own text twice (body + Reasoning card).
Room v5 migration adds tool_call_id; regression tests use captured
on-device payload shapes.
* fix(chat): flush throttled tokens before orphan seal + prefix-safe dedupe (#842)
The tool.start seal raced the 33ms throttled token flush: deltas landing just
before the transition stayed in the buffer, so the sealed narration orphan came
out truncated (on-device capture: orphan ended '...chickpea', backend persisted
'...chickpea goodness:') and failed the logical-match against the clean REST
row — the commentary ghosted on the next transcript sync.
- ChatStreamingController.flushPendingTokens(): force-flushes the throttled
TOKEN buffer on MessageStart/MessageComplete/MessageDone/ToolStart so the
reducer seals the COMPLETE narration.
- sameLogicalMessage: accept prefix-covering (both sides >=40 chars) so a
residual seal drift can never re-add a substantial narration.
- Regression tests: controller flush + truncated-orphan prefix match.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(chat): meter flashes stale window on mid-chat model swap (issue #817)
session.info updates the model label as soon as the backend applies a
swap, but the context meter's denominator kept the old model's window
until the next fetch — the chip briefly rendered the new label over the
old window (e.g. 1M flashing before settling on 262k).
- session.info with a DIFFERENT model now blanks the denominator (chip
hides while resolving) and immediately re-fires fetchContextUsage()
- fetchContextUsage writes the denominator ONCE, atomically: RPC
context_max wins, REST model/info is the fallback — a stale pre-swap
response can never override a fresh one
- regression tests: swap re-fires the fetch and lands the new window;
an identical session.info leaves the meter untouched
* fix(chat): don't fall back to profile REST window on model swap (issue #817)
After a session-scoped swap the live agent's context_breakdown can
return empty (context_max 0) while the runtime warms up. The refetch
then fell back to GET /api/model/info — PROFILE-scoped — which reports
the profile's main-model window (e.g. 1M), resurrecting the stale
window under the new label. The swap-triggered refetch now trusts only
the RPC's live context_max; the chip stays hidden until the real
window lands.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
The gateway replays the full transcript over a new socket on
session.resume unless omit_messages is set; mobile hydrates history via
REST in parallel, so the replay is redundant. When a reconnect lands
mid-stream, the replayed message gets a fresh UUID and dedup can't
collapse the 90% orphan with the 100% replay (different ids + content),
leaving a duplicate bubble. Desktop already sends omit_messages: true;
mirror that in both resume paths (ChatViewModel + NotificationReplyReceiver)
and pin it in the exact-map test verifies.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(sessions): empty-session cleanup button + latest-descendant resume + fresh-session 404 fix (issues #787, #840)
- Sessions top bar: Delete-empty icon with count badge, grey/disabled when 0
(GET /api/sessions/empty/count + DELETE /api/sessions/empty, confirm dialog)
- Resume flow resolves GET /api/sessions/{id}/latest-descendant first —
branched sessions continue in the newest child leaf, not the stale parent
(best-effort; falls back to the requested id)
- syncCurrentSession guarded by sessionHasServerPresence — a freshly created
(never prompted) session has no server row yet; the REST transcript 404s
and the resume retry spun forever (logcat 404 evidence)
* fix(sessions): clamp message-count fallback to backend limit cap (422)
fetchServerMessageCount requested limit=500 but the backend caps
session list page size at 100 (sessions.py Query le=100) — every
fallback scan 422'd (seen in device logcat after a branch). Sessions
are ordered recent, so the target is always in the top page.
* test(slash): make dispatch-capture assertions order-independent (CI flake)
The capture-race flake (SlashCommandDispatchRpcTest, hit 3x across CI and
local runs 2026-08-07): tests asserted on methodSlot.captured — the LAST
request captured — but the create flow's session.usage/context_breakdown
requests can land after the slash-dispatch one depending on suite order
(the class's own comment documents this cross-wiring). Converted the
three capture-based tests to capture ALL requests and find the target
record by method, with dispatch-before-fallback ordering for the 4018
test. Same assertions, no dependence on request interleaving.
* test(slash): convert remaining capture-race assertions (flake round 2)
The capture race moved to the focus/init tests (:424) after the first
three were fixed — every request-capture test in the class used the
same last-captured assumption. Converted the remaining five (init,
init+arg, focus, focus on, type=send) to the find-record-by-method
pattern; the type=send answers block reads methodCalls.last() since the
capture precedes the answer. Zero request() slot captures remain.
* test(slash): blocklist assertion counts command RPCs only (flake round 3)
The blocklisted-command test counted ALL request() calls and asserted
zero — the create flow's session.usage/context_breakdown noise can land
in the window after the stub registers (the same capture race that hit
the dispatch tests). Asserted instead that no COMMAND_DISPATCH or
SLASH_EXEC fires, which is the actual blocklist contract. E2E's static
Dispatchers mock stays (E2E VMs need IO routed to the test dispatcher —
removing it broke 20 E2E tests).
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* 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>
Replaces the `indexOfFirst` linear scan over the unmerged incoming messages with a `HashMap` mapping message IDs to their index. This reduces the time complexity of the server message reconciliation loop from O(N^2) to O(N). Additionally, `removeAt()` calls on the unmerged list (which carry an O(N) penalty due to array shifting) were replaced by setting matched indices in a `MutableList<ChatMessage?>` to `null`.
First open creates a session whose DB row the gateway persists lazily on the
first prompt, so a freshly created (never prompted) session CANNOT be resumed.
Any WS reconnect while sitting on it — tab switch + socket blip, app
background, gateway restart — re-fired session.resume on that key; the gateway
answered 4007 "session not found" and the bounded retry (desktop parity, #766)
burned its budget on a permanently invalid key, leaving an error popup whose
Retry could never succeed.
- Skip the reconnect re-resume (and the doomed REST transcript fetch) while the
current session has no confirmed server-side row (REST 200 / resume success /
first MessageStart = first prompt accepted). Sessions picked from History
stay optimistic-resumable even while their page transiently 5xxes.
- Treat a definitive "session not found" (4007 RPC / 404 REST) as terminal:
recover with a fresh chat + notice instead of the dead-end Retry overlay.
- session.branch: keep the storage key in currentSessionId (the handler stored
the runtime registry id, so every later resume 4007'd and the transcript 404).
Tests: reconnect on an unconfirmed session must not resume or error; 4007 must
recover with a new session (deduped, notice shown); branch keeps the storage id
(currentSessionId + transcript fetch key). RED-GREEN proven: all three fail on
the pre-fix code and pass with it.
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>
Opening a session from history fired session.context_breakdown/session.usage
with the storage session id before session.resume had registered it on the
gateway. Those RPCs resolve against the LIVE runtime registry (_sess_nowait)
and returned 4001 "session not found", which the reducer surfaced as a
spurious snackbar even though the session loaded fine.
- fetchContextUsage() now skips the two live-registry RPCs while
runtimeSessionId is null (pre-resume window); the REST-based context
fallbacks stay live.
- runtimeSessionId is cleared on socket drop so a stale runtime id can't be
reused after a gateway restart/reconnect (same 4001 family).
- regression test: no context/usage RPC fires with the storage id during the
pre-resume window; after the resume result the RPCs fire with the runtime id.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Bring the desktop's session reconnection UX to mobile:
- Warm-cache fast-path: switchSession paints the cached Room transcript
immediately instead of blanking the screen, then refreshes from the
server in parallel (stale cache can't clobber a fresher server page)
- Bounded auto-retry: a failed session.resume (WS reject or REST
transcript failure) retries with exponential backoff 1s->2s->4s->8s,
capped at 4 attempts; WS + REST failures in the same cycle count as one
- Explicit error recovery: after exhaustion the chat shows an error card
with a manual Retry instead of latching the spinner forever; manual
retry resets the backoff cycle
- Reconnect-aware resume: gateway (re)connect clears stale error state
and re-resumes the current session on the new socket
- Unit tests: warm-cache paint, bounded retry exhaustion, WS+REST
dedupe, manual retry reset, reconnect restart
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(chat): 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>
* 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>
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>
* 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
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>
* feat(chat): full-screen image viewer (issue #723)
Tap any chat image (markdown , inline attachment thumbnail,
or bubble image) to open a full-screen viewer:
- pinch-zoom + pan via native detectTransformGestures
- downswipe-to-dismiss at min zoom
- Save -> device Downloads (MediaStore), Share -> Android share sheet
(both fully device-local; never sent back to the Hermes server)
Plumbing: onImageClick(ImageViewerModel) flows MarkdownText ->
ChatBubble/InlineAttachment -> ChatMessageList -> ChatScreen (state +
ImageViewerDialog). ImageBytesResolver reuses the existing authed
OkHttpProvider path for gateway http(s) sources; data:/content://
handled locally. No new dependencies.
Closes#723
* fix(chat): resolve strings at composable scope to satisfy LocalContextGetResourceValueCall lint
Lint forbids reading resource *values* from LocalContext.current inside
coroutine lambdas. Hoist all viewer strings via stringResource() at
composable scope and reuse them in onSave/onShare.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(#724): inline host-path MEDIA: images on mobile (client-side)
The previous approach re-fetched the REST session-messages endpoint, but that
endpoint returns the raw MEDIA:<path> text (api_server._message_response does
not inline, and the transcript stores raw content). The desktop app instead
reads the file locally via readFileDataUrl because it is co-located with the
host. Mirror that on mobile: on assistant message.complete/done, inline any
MEDIA:<path> image into a base64 data:image/... markdown tag the renderer can
show. Mobile-only, backend untouched. Unreachable tags are stripped (graceful
degrade). Pure inlining logic extracted to MediaInline for unit testing.
* fix: correct RegexOption name (IGNORE_CASE) in MediaInline
* test: fix byte literal types in MediaInlineTest
* fix(#724): rewrite host-path MEDIA: to gateway /api/files/download URL
Pivot from the earlier base64-inline approach to the desktop-proven,
remote-phone-working fix: rewrite MEDIA:<path> directives (delivered raw over
the WS stream) into authenticated
 markdown tags,
mirroring desktop media.ts mediaExternalUrl. The mobile renderer (MarkdownText)
shows http(s) ![]() via Coil, so agent-sent images now render on mobile too —
including on a remote phone (real HTTP, unlike a host-local file read).
Mobile-only, backend untouched. Pure rewrite logic in MediaUrlRewriter
(replaces MediaInline) with unit tests.
* feat(#724): full gateway-file support — attach MEDIA: files (not just images)
Pivot the MEDIA: fix from image-only URL rewriting to real attachments:
- Attachment: add gatewayUrl + AttachmentSource (LOCAL/GATEWAY) so
gateway-hosted files are first-class and render via existing InlineAttachment.
- HostMediaExtractor (replaces MediaUrlRewriter): parse ANY absolute MEDIA:
directive (any extension) and strip them from message text.
- ChatViewModel.attachHostMedia: build the authed /api/files/download URL via
GatewayFileClient, classify via MediaTypes, attach as Attachment (images
inline, every other file tappable). Idempotent.
- InlineAttachment: tappable. Gateway files fetch via GatewayFileClient and
share via a chooser Intent (FileProvider); images load the URL in Coil.
- Hence agent-delivered images, audio, video, CSV, PDF and arbitrary files all
work on mobile — including a remote phone. Backend untouched.
Builds on #726 (GatewayFileClient). Rebased onto main after #726 merged.
* fix(#724): resolve imports, bubble callback scope, and quoted media path regex
* feat(chat): open attachment in-place on tap (#724)
Tap opens LOCAL (content://) and GATEWAY (fetch->FileProvider) files via
ACTION_VIEW; failures surface as a non-blocking snackbar via openError.
* fix: remove redundant out-of-order io.mockk.* wildcard import (ktlint CI)
ktlint import-ordering requires lexicographic order; the trailing wildcard
'import io.mockk.*' sorts before 'io.mockk.coEvery' and was redundant — every
mockk symbol is already explicitly imported, and the single mockkConstructor
call uses a fully-qualified io.mockk. prefix (needs no import).
Fixes ktlint failure on PR #725.
* fix(ci): mock android.jar Intent stub so openAttachment GATEWAY test passes
android.jar unit-test stubs Intent.setDataAndType/addFlags to throw
'... not mocked'. openWithView builds an Intent and calls them, so
openBytes' runCatching swallowed the exception and startActivity never
fired, failing the test. Mock the Intent constructor + setters and
capture the constructed instance to assert the ACTION_VIEW contract.
* fix(#724): resolve host media download URLs and deduplicate REST synced chat bubbles
* fix(#724): add FLAG_ACTIVITY_NEW_TASK for file intents and update GatewayFileClientTest
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat: 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>