- CronJob model gains optional monitor_script/monitor_url (additive-safe)
- Job editor: Monitor mode section (off/script/url) with XOR source clearing;
disabled while no-agent is set (backend invariant); new jobs apply monitor
via create -> follow-up PUT with rollback delete on failure (the REST create
model drops the fields)
- Job list/details: blocked_config renders as a distinct error badge
('Blocked (config)'), no_change as informational; monitor source shown in
run details
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(sessions): show Messages stat + slim prune button
The Active stat card always rendered 0: the mobile model declared
'val active: Int = 0', a key GET /api/sessions/stats never sends
(backend returns total/active_store/archived/messages/by_source), and
with ignoreUnknownKeys = true deserialization silently fell back to 0.
Rework the stats row instead of just key-mapping:
- Messages card replaces Active, reading the 'messages' key
(db.message_count(), web_routers/sessions.py:523-550)
- Prune becomes a slim 52dp icon-only card (contentDescription keeps it
accessible) so the Messages card keeps width for large counts
* feat(sessions): compact stat counts (100k / 1.23m) for Total & Messages
formatCompactCount renders counts as max 3 digits + unit letter:
987 -> '987', 100987 -> '100k', 1234567 -> '1.23m', 100000000 -> '100m'.
Applied to both the Total and Messages stat cards so large numbers fit
the row. Covered by SessionsStatsFormatTest (5 test cases).
* fix(sessions): label prune + empty-cleanup buttons, drop top select toggle
- Prune card now sizes to its label (icon + 'Prune' text) instead of a
52dp icon-only square, staying compact via wrap-content width.
- Empty-session cleanup becomes a labeled FilledTonalButton (icon +
'Delete empty sessions', count badge kept) instead of a bare icon.
- Removed the top-bar select toggle (SelectAll icon); selection mode
remains reachable via long-press > Select on any session row.
- Dropped now-orphaned content_desc_enter/exit_selection strings (en/ko).
* fix(sessions): prune card wrap-content width + shorter cleanup label
The prune card dropped its fixed 52dp width but kept fillMaxSize on the
inner Box — as an unweighted Row child that expands to the full row
width, starving the weighted Total/Messages cards (reported broken on
device). The Box now fills height only, so the Card wraps to its
content (icon + 'Prune').
Empty-session cleanup button label shortened to 'Delete empty'.
* fix(sessions): stack prune label under icon, cleanup button reads 'Empty'
- Prune card: trash icon above the 'Prune' label (column layout),
still wrap-content width so Total/Messages keep their space.
- Empty-session cleanup button label shortened to 'Empty'.
---------
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>
* 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>
* feat(toolsets): per-toolset config/env/provider/post-setup management (#782)
* fix(toolsets): per-entry toolset name for shared detail ViewModel (#782)
NavDisplay uses the activity-level ViewModelStoreOwner, so class-keyed
viewModel{} returns the same ToolsetDetailViewModel for every detail
entry; the constructor-captured name froze the first-opened toolset into
all entries (on-device: every toolset showed web's providers, no further
/config calls after the first). The screen now drives setToolset(name) +
loadConfig() per entry, and setToolset resets per-toolset state
(config, revealed secrets, running post-setup) so nothing bleeds across
toolsets.
* style(toolsets): single-line scrollable tool chip strip (#782)
A wrapping FlowRow stacked chips vertically for toolsets with many
tools (web etc.), ballooning card height — especially on large font
scales. Chips now sit on one horizontally scrollable row.
* chore(toolsets): drop stray terminal-noise line from chip strip change
---------
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>
- New-job dialog gains a Start-from dropdown: pick a catalog blueprint, fill its typed slots (time/enum/weekdays/text), save instantiates it via POST /api/cron/blueprints/instantiate
- Delivery field is now an editable picker fed by GET /api/cron/delivery-targets (origin + local + connected platforms)
- Blueprints and targets fetched on editor open; 422 slot errors surface the backend detail
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(profiles): delete, rename, auto-describe and setup command (issue #781)
* feat(profiles): use shared ModelPickerDialog for set model (issue #781)
* fix(profiles): move overflow menu to card header so it can't be clipped (issue #781)
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* perf: remove runBlocking from PersistentCookieJar to fix overhead
* perf: remove runBlocking from PersistentCookieJar to fix overhead
* perf: fix PersistentCookieJar race condition using CountDownLatch
* perf: remove runBlocking from PersistentCookieJar to fix overhead
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>
* 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
* fix(#724): inline host-path MEDIA: images on mobile (client-side)
The previous approach re-fetched the REST session-messages endpoint, but that
endpoint returns the raw MEDIA:<path> text (api_server._message_response does
not inline, and the transcript stores raw content). The desktop app instead
reads the file locally via readFileDataUrl because it is co-located with the
host. Mirror that on mobile: on assistant message.complete/done, inline any
MEDIA:<path> image into a base64 data:image/... markdown tag the renderer can
show. Mobile-only, backend untouched. Unreachable tags are stripped (graceful
degrade). Pure inlining logic extracted to MediaInline for unit testing.
* fix: correct RegexOption name (IGNORE_CASE) in MediaInline
* test: fix byte literal types in MediaInlineTest
* fix(#724): rewrite host-path MEDIA: to gateway /api/files/download URL
Pivot from the earlier base64-inline approach to the desktop-proven,
remote-phone-working fix: rewrite MEDIA:<path> directives (delivered raw over
the WS stream) into authenticated
 markdown tags,
mirroring desktop media.ts mediaExternalUrl. The mobile renderer (MarkdownText)
shows http(s) ![]() via Coil, so agent-sent images now render on mobile too —
including on a remote phone (real HTTP, unlike a host-local file read).
Mobile-only, backend untouched. Pure rewrite logic in MediaUrlRewriter
(replaces MediaInline) with unit tests.
* feat(#724): full gateway-file support — attach MEDIA: files (not just images)
Pivot the MEDIA: fix from image-only URL rewriting to real attachments:
- Attachment: add gatewayUrl + AttachmentSource (LOCAL/GATEWAY) so
gateway-hosted files are first-class and render via existing InlineAttachment.
- HostMediaExtractor (replaces MediaUrlRewriter): parse ANY absolute MEDIA:
directive (any extension) and strip them from message text.
- ChatViewModel.attachHostMedia: build the authed /api/files/download URL via
GatewayFileClient, classify via MediaTypes, attach as Attachment (images
inline, every other file tappable). Idempotent.
- InlineAttachment: tappable. Gateway files fetch via GatewayFileClient and
share via a chooser Intent (FileProvider); images load the URL in Coil.
- Hence agent-delivered images, audio, video, CSV, PDF and arbitrary files all
work on mobile — including a remote phone. Backend untouched.
Builds on #726 (GatewayFileClient). Rebased onto main after #726 merged.
* fix(#724): resolve imports, bubble callback scope, and quoted media path regex
* feat(chat): open attachment in-place on tap (#724)
Tap opens LOCAL (content://) and GATEWAY (fetch->FileProvider) files via
ACTION_VIEW; failures surface as a non-blocking snackbar via openError.
* fix: remove redundant out-of-order io.mockk.* wildcard import (ktlint CI)
ktlint import-ordering requires lexicographic order; the trailing wildcard
'import io.mockk.*' sorts before 'io.mockk.coEvery' and was redundant — every
mockk symbol is already explicitly imported, and the single mockkConstructor
call uses a fully-qualified io.mockk. prefix (needs no import).
Fixes ktlint failure on PR #725.
* fix(ci): mock android.jar Intent stub so openAttachment GATEWAY test passes
android.jar unit-test stubs Intent.setDataAndType/addFlags to throw
'... not mocked'. openWithView builds an Intent and calls them, so
openBytes' runCatching swallowed the exception and startActivity never
fired, failing the test. Mock the Intent constructor + setters and
capture the constructed instance to assert the ACTION_VIEW contract.
* fix(#724): resolve host media download URLs and deduplicate REST synced chat bubbles
* fix(#724): add FLAG_ACTIVITY_NEW_TASK for file intents and update GatewayFileClientTest
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat: add Files tab backed by the managed-files endpoint
Add a Hermes Mobile tab that browses the gateway's managed-files root via the
existing /api/files* backend routes: list/navigate folders, breadcrumb path,
create folder, upload (streaming multipart), delete, and open files via the
authenticated GatewayFileClient download URL.
- data/model/ManagedFile: entry + list/read/upload/mkdir/delete models
- HermesApiService: list/read/upload(+stream)/mkdir/delete endpoints
- FilesViewModel: state, navigation, per-row busy, transient-state cleanup
- FilesScreen: folder list, breadcrumb, FAB new folder, upload picker,
delete confirm, open via system viewer
- NavigationKeys + ScreenRegistry + strings: register in Inspect drawer section
* fix(files): open files in-app via FileProvider instead of browser URL
The previous file-open path handed a ?token=-authenticated URL to the system
browser, but /api/files/download only accepts the ephemeral in-process session
token as ?token=, not the mobile's Bearer token — so the request failed and the
browser rendered the JSON error body instead of the file.
Fix: download the file in-app through the authenticated client (Bearer/
session-cookie, same path as the rest of the app), decode the base64 data_url,
write it to cacheDir, and open it via FileProvider.getUriForFile + ACTION_VIEW
(mirrors the existing ChatScreen/SystemViewModel open-from-cache pattern).
Works on a remote phone since the file is materialized locally first.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* 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>
* Adopt /api/health REST route (issue #713)
Cheap liveness probe on the connection screen: GET /api/health surfaces
backend version + auth mode (Token vs Gated/OAuth) without the heavier
/api/status payload. Added HermesApiService.getHealth + HealthStatus model;
ConnectViewModel.loadHealth probes on open (best-effort, silent on failure);
ConnectScreen shows a read-only health card when reachable.
Verified: ktlint clean, assembleDebug BUILD SUCCESSFUL locally.
* Add used/full context meter to chat screen
Shows session prompt tokens vs model context window above the composer,
polled every 5s. Color-coded bar warns at 70%/90%.
* fix(chat): fix unit tests for context usage meter
Format >=100k tokens cleanly in ContextUsageChip and stub AuthManager profile ID in ChatViewModelTest.
* Fix context meter: source used tokens from input_tokens
Gateway /api/sessions/{id} has no last_prompt_tokens column; use
input_tokens instead. Show meter as soon as full context is known.
* Add tappable context breakdown sheet
Chip opens a bottom sheet with input/output/cache/read/write/reasoning
token rows + message count. Source breakdown from /api/sessions/{id}.
* Refresh context meter on streaming complete
Trigger fetchContextUsage immediately when a message finishes streaming
instead of waiting for the 5s session-sync poll.
* Fix context meter: source used tokens from live WS session.info
The REST /api/sessions/{id} endpoint reads the DB input_tokens column,
which is stale/0 for active sessions — that's why the meter was stuck at 0.
Switch the meter to the live values the gateway pushes over the WS
session.info payload after each turn:
- used = last_prompt_tokens (real used context for the turn)
- full = context_length (model-accurate window; keep /api/model/info as a
fallback before the first turn completes)
fetchContextUsage now only tops up the denominator from /api/model/info and
no longer overwrites the live WS value with the stale REST input_tokens.
The 5s poll remains as a fallback.
* Revert "Fix context meter: source used tokens from live WS session.info"
This reverts commit 932393fd80993d57e66fe93552a949460d646674.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* 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>
* perf: optimize synchronous I/O in AuthLoginViewModel
Replace blocking OkHttp .execute() with a suspending .await() extension
(CallExt.kt) backed by suspendCancellableCoroutine, freeing Dispatchers.IO
threads during network waits. Probe/connect paths made suspend.
- Call.await() guards both onResponse/onFailure with continuation.isActive
to avoid AlreadyResume on cancellation.
- Removes non-asserting AuthLoginViewModelBenchmark timing dump.
* perf: optimize synchronous I/O in AuthLoginViewModel
Replaced the blocking `.execute()` OkHttp calls with a suspending `.await()` extension function inside `AuthLoginViewModel`. This frees up the `Dispatchers.IO` threads by deferring the blocking operations to OkHttp's internal task runner, providing a safer async approach and preventing Thread pool starvation on slow network responses.
Modified `probeDashboardInternal` and `connectBasicAuth` to be proper `suspend` functions and resolved corresponding unit tests.
* fix(test): add missing import io.mockk.any in AuthLoginViewModelTest
* fix(test): fix mockk imports and Log matchers in AuthLoginViewModelTest
* fix(test): use persistent mockProbeClient in AuthLoginViewModelTest
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
In gated mode (basic-auth cookie flow) the dashboard 401s any request that
carries an Authorization: Bearer header alongside the valid session cookie
(verified live 2026-07-23). ApiClient.authInterceptor stamped the WS ticket
as a Bearer header on every REST call, so every non-chat tab (skills, cron,
config, tools, mcp, ...) failed with "token expired" while the WS chat
(?ticket=) kept working.
- Add AuthManager.isGatedMode() (wsAuthParam == "ticket").
- authInterceptor skips the Bearer header in gated mode; loopback/token
behavior unchanged.
- TokenRefreshAuthenticator bails in gated mode instead of attempting the
loopback-only SPA token refresh.
- ApiClientTest: add gated/loopback regression tests. 7/7 pass, ktlint clean.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* Test ServerCollectionOps data manipulation utilities
Added comprehensive unit tests for `ServerCollectionOps.kt` to cover all scenarios for `addOrUpdate`, `selfHealed`, and resolution properties (`resolvedBaseUrl`, `resolvedHost`, `resolvedPort`). Also updated the function name `addOrReplaceServer` to `addOrUpdate` to match the intended signature in the issue description, and updated affected call sites.
* Fix ktlint formatting issues and ensure build succeeds
Fixed ktlint errors in `ServerCollectionOpsTest.kt` by correctly formatting arguments and expressions to comply with project style guidelines. Ran `assembleDebug` and tests locally to verify build health.
* fix(#688): route expired auth sessions to sign-in
Adopt global 401 latch, WS disconnect, and Back/profile guards.
* chore: clean up formatting noise
* fix(ktlint): reorder imports to place java and kotlinx at end
* fix(ktlint): place java.io.IOException at end of imports
* test: fix NavBackStack type parameter in AuthenticationRoutingTest
* test: add AuthSessionState tests to AuthLoginViewModelTest
* fix(ktlint): fix import order in AuthLoginViewModelTest
* fix(#686): implement robust history pagination
Honor server-returned offset, degrade gracefully on older servers, and parse JSON tool results.
* fix(ci): repair unit-test compilation and pagination test mocks
- Remove trailing blank line in SessionMessagesResponse.kt (ktlint)
- Replace unresolved io.mockk.eq/coAnswers usage in ChatViewModelTest with
an ordered-call counter inside coAnswers, which compiles and runs on
MockK 1.14.11 (eq is a matcher-scope member, not a top-level import)
- Align HermesApiServiceMockWebServerTest with the PR's JsonElement content
model (contentText) and the new include_compacted=true default query param
All unit tests pass locally with the ByteBuddy agent preload.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>