Adopt GET /api/hermes/update/receipt and append its summary to the
end of the hermes-update action progress popup after it finishes.
Additive only: existing checkHermesUpdate + connection-retry polling
is untouched. 404 (old backend) is handled gracefully.
- add UpdateReceiptResponse data model matching backend schema
- add getUpdateReceipt() to HermesApiService
- ActionProgressController gains trailingLines + pushTrailingLines
- ActionProgressDialog renders trailingLines after the log
- SystemViewModel fetches receipt on action finish and formats it
- extract pure formatUpdateReceiptLines for mockk-free testing
Add ModelCapabilities (capabilities: Map<String, ModelCapabilities>?)
to ModelProvider matching backend /api/model/options shape
{model: {fast, reasoning, can_disable_reasoning}}. Nullable/defaulted
for backward compat with old backends (ignoreUnknownKeys+SnakeCase).
Chat composer: gate reasoning dropdown on can_disable_reasoning and
supportsReasoning — "None" disabled with "reasoning always on" hint
when can_disable==false, chip disabled with "No reasoning" when
reasoning==false. Missing keys offer full scale.
Sync currentModelCapabilities from cachedModelOptions on
SessionInfo, resume, preload/refresh, and optimistic /model swap;
clear on resetSessionState. Used by ComposerToolbar via ChatScreen.
Model picker: surface same hints in ModelPickerDialog (pinned+all)
and ModelScreen ProviderCard.
Verified: ktlintCheck OK, compileDebugKotlin OK (rerun-tasks).
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(sessions): fork icon + depth number, no indentation
Multiply-nested forked sessions rendered with deep indentation that crushed
the card off-screen and made titles unreadable, and the per-sibling order
number was uninformative (mostly "1").
- SessionTreeItem: replace forkIndex (sibling order) with forkDepth (nesting
level: 1 = child of a root, 2 = fork of a fork, ...); flattenSessionTree
populates it.
- SessionCard: render CallSplit fork icon + the depth number (primary tint).
Removed the depth-based start padding so names stay full-width readable —
the icon+number now carries the structure instead of indentation.
- strings: sessions_fork_indicator (en/zh/ko).
- SessionTreeTest: assert isFork/forkDepth across nested fixtures.
No backend change — backend already returns parent_session_id per SessionInfo.
* test(ws): make testAutoReconnect tolerate transient RECONNECTING state
The test gated on connectionStatus.first { it == RECONNECTING } with a
15s timeout. With the test backoff forced to 0ms the RECONNECTING state is
emitted + consumed before the collector attaches, so the wait missed it and
timed out (kotlinx.coroutines.TimeoutCancellationException) — the flake CI
saw under parallel load.
- Replace the brittle RECONNECTING .first{} wait with a tolerant check that
accepts RECONNECTING-or-later (the reconnect clearly fired).
- Keep connect2Latch (second socket opening) as the definitive proof + add a
final CONNECTED assertion.
- Reset the test backoff in a finally so later tests never inherit 0ms.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Backend stamps last_fire_error ({at, detail}) on a job when a scheduled fire
can't reach the gateway loopback (gateway down / api_server not bound) —
the "job runs manually but never auto-fires" case. Previously invisible in
mobile.
- CronJob.kt: model as CronJobFireError(at, detail) — the backend sends a
nested object, NOT a flat string (issue's String? proposal would fail to
deserialize), plus the last_fire_error field.
- CronJobsScreen: "Fire failed" ERROR badge in the list + "Dispatch Error"
row (detail + timestamp) in the Run Details dialog.
- Strings en/zh/ko. Test: job with last_fire_error loads + parses.
Backward-compatible: field has a default, existing jobs without it parse fine.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
- Hold-menu Pin/Unpin action on session cards (PATCH /api/sessions/{id}
with {pinned}, verified against the backend SessionRename model)
- Pinned sessions sort to the top on every load (client-side stable sort
— the backend back-fills pins into page 1 but does not lift them)
- Pin indicator on pinned cards; search-result cards omit the pin action
- Tests: pin-first sort on load, toggle success (moves to top), failure
(order kept + toast)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
- 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>
- 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>
* 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>