* 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>
* refactor(chat): extract tool display subsystem from ChatBubble.kt
ChatBubble.kt was a 2578-line god file. Split it into four files in the
same package (zero import churn, no behavior change):
- ChatBubble.kt: user/assistant/system bubbles + dispatch + shared
helpers (formatTimestamp, buildHighlightedString, InlineAttachment)
- ParsedToolData.kt: the parsed tool payload model
- ToolResultParser.kt: parseToolOutput + per-tool formatters + the
Gson-compat JSON shims and tolerant exit_code parser
- ToolBubble.kt: ToolBubble + ExpandedToolContent + HeaderRow +
SecurityRiskChip + CopyButton
formatTimestamp became internal (shared with ToolBubble.kt).
Verified: ktlintCheck + testDebugUnitTest green locally.
* refactor(chat): drop dead ParsedToolData fields and unused shim
Verified via grep: parsed.args / parsed.result / parsed.stderr /
parsed.isRunning / parsed.toolName had zero readers across app/src
(main + test + androidTest). toolName + isRunning are asserted by
ToolBubbleParsingTest, so they stay; args/result/stderr were built by
every formatter (21 construction sites) and never read — removing them
also kills a JSON.stringify-per-tool-complete wasted map build.
keySet() shim had no call sites. formatTodoToolOutput's args param
became unused with the field removal — dropped it too.
Verified: ktlintCheck + testDebugUnitTest green locally.
* refactor(chat): drop unused lastSessionId and ToolBubble isDarkTheme param
ChatScrollController.lastSessionId: introduced in #683, never read or
written since (namefreq scan + git log -S confirm single occurrence at
declaration). Session-change detection moved to lastTailKey/
lastMessageCount.
ToolBubble.isDarkTheme: never read in the body (MaterialTheme +
statusColors do the theming); single call site updated.
Verified: ktlintCheck + compileDebugKotlin green locally.
---------
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>
* 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
Clears stale context token states on session switch/creation and fetches
fresh context usage info immediately instead of waiting for the 5s poll.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Use MaterialTheme.colorScheme tokens for Assistant, System, and Tool bubbles so that all presets and dynamic colors render properly without theme bleed.
* 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: 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>
Assistant messages could not display inline images or attachments on the
mobile client. The markdown renderer had no image block, and
AssistantBubble only rendered MarkdownText + timestamp, ignoring
message.attachments (only UserBubble did).
- Add MdBlock.Image + standalone  parsing in MarkdownText.
Accepts http(s) gateway URLs and data:image/...;base64 inline media.
- Render message.attachments inside AssistantBubble (mirrors UserBubble)
via InlineAttachment / AsyncImage.
- Add parser unit tests for image blocks (url, data: URL, inline-in-paragraph).
Local verification: ./gradlew ktlintCheck compileDebugKotlin assembleDebug
all green; app-debug.apk produced.
Fixes feature gap where agent-delivered media (e.g. MEDIA:/ images
sent to chat) was invisible in the Android app.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* 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>
Flush throttled reasoning before state-transition events (MessageStart/
Complete/Done/ToolStart) so a reasoning delta arriving just before a
transition is not dropped. Also compare reasoningText in sameMessages so
a reasoning-only restore actually re-merges.
- ChatStreamingController.flushPendingReasoning() forces a synchronous flush
- handleWsEvent calls it for the transition events
- sameMessages now includes reasoningText in its equality check
- tests: ChatStreamingControllerTest (flush + no-resurrect), reasoning survives
MessageDone after an unflushed delta
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Fix active saved profile visibility in Catppuccin light mode and Monochrome light/dark modes by adjusting primaryContainer contrast and adding a primary border stroke.
* fix(mcp): make Add Server + Catalog headers read distinctly from buttons
Both section headers reused the shared SectionHeader, which paints the
title in primary/bold — so each header row looked like a tappable
button next to its trailing TextButton. Add a scoped McpSectionHeader
with a distinct leading icon (➕ Add / 🗄 Explore) and a neutral
onSurface bold title, so they read as headers and look different
from each other.
* fix(mcp): give section header row + action button distinct backgrounds
- Whole header row now sits on a surfaceContainerLow rounded (12dp) surface
so it reads as a contained section header, not bare text.
- The trailing New/Browse action uses secondaryContainer (onSecondaryContainer
text) — a clearly different color from the row background, so button
vs header are visually distinct.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(keys): overhaul Keys screen UI and UX
Fix card padding alignment, add delete confirmation dialog, add FAB with AddKeyDialog, support copying revealed values, add status badges, and pin restart banner.
* fix(keys): fix scaffold actions parameter and missing string resource
Resolve compilation errors in KeysScreen by removing floatingActionButton from HermesScaffold and adding missing keys_action_open_url resource.
* feat(keys): replace dropdown headers with horizontal category filter chips
Add CategoryFilterBar featuring horizontal FilterChips for quick category filtering (All, LLM Providers, Tool API Keys, Messaging Platforms, Agent Settings) with total key counts.
* style(keys): enhance key card padding, spacing, borders, and surface contrast
Use surfaceContainerLow with subtle outlineVariant border and 16dp rounded corners for high contrast cards, and set 12dp list spacing between cards.
* fix(keys): wrap key title and status badge in FlowRow to prevent layout overflow on long key names
Prevent status badge from squishing or overflowing when displaying long API key names by using a FlowRow that wraps gracefully.
* fix(keys): add file-level OptIn for ExperimentalLayoutApi
Fix compilation error by adding @file:OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) to KeysScreen.kt.
* fix(keys): fully qualify class names in file-level OptIn annotation
Fix unresolved reference build error by fully qualifying ExperimentalMaterial3Api and ExperimentalLayoutApi in @file:OptIn before package statement.
* fix(keys): remove invalid verticalAlignment parameter from FlowRow
* fix(keys): optimize long API key name readability and wrapping
Insert zero-width spaces after underscores to ensure clean word-boundary wrapping, add adaptive typography (titleSmall with 20sp line height for long names), and stack StatusBadge cleanly below the title.
* feat(keys): make API key names and values single-line and horizontally scrollable
Enforce singleLine=true / maxLines=1 with horizontalScroll(rememberScrollState()) for both key names and revealed key values so cards remain compact with uniform heights.
* feat(keys): keep single-line scrollable key values and clean multi-line key names
Keep secret key values single-line and horizontally scrollable, while restoring clean multi-line word-boundary breaking (at underscores) for key names.
* style(keys): format KeysScreen.kt with ktlint
Tighten FilledTonalButton contentPadding on the MCP server card action
row so the Restart label fits beside the icon in the weighted half-width
slot (default 24dp horizontal padding was eating the text).
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(skills): redesign tab switcher and fix status filter alignment and card padding
* fix(skills): replace status filter chips with SingleChoiceSegmentedButtonRow for precise alignment
* fix(skills): left-align status, category, and source filter chip rows
* fix(skills): explicitly left-align status FilterChips with 16dp start margin
* fix(skills): match status filter chip row styling and All label to category row below it
* fix(skills): remove extra Row wrapper padding from status filter chips in SkillsScreen
* feat(skills): enhance card background contrast and reduce side padding for Installed and Hub cards
* feat(skills): reduce card/search padding to 8dp and pin top controls with weight(1f) on LazyColumn
* style(skills): fix ktlint import-ordering and remove unused imports (PR #704)
---------
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>
* perf: Hoist regexes in MarkdownText.kt to avoid recompilation
Resolves a performance issue where `bulletRe`, `taskRe`, and `orderedRe`
were instantiated and compiled inside the `parseBlocks` function on every
call, causing unnecessary overhead during repeated markdown parsing (e.g.,
when chat messages stream in).
These regex objects have been hoisted to file-level `private val`
constants (`BULLET_RE`, `TASK_RE`, `ORDERED_RE`), ensuring they are compiled
only once when the class is loaded.
A benchmark `MarkdownTextBenchmark.kt` was added to verify the
performance change, showing an approximate 50ms improvement over 10k
iterations.
* perf: Hoist regexes in MarkdownText.kt to avoid recompilation
Resolves a performance issue where `bulletRe`, `taskRe`, and `orderedRe`
were instantiated and compiled inside the `parseBlocks` function on every
call, causing unnecessary overhead during repeated markdown parsing (e.g.,
when chat messages stream in).
These regex objects have been hoisted to file-level `private val`
constants (`BULLET_RE`, `TASK_RE`, `ORDERED_RE`), ensuring they are compiled
only once when the class is loaded.
A benchmark `MarkdownTextBenchmark.kt` was added to verify the
performance change, showing an approximate 50ms improvement over 10k
iterations.
* perf: Hoist regexes in MarkdownText.kt to avoid recompilation
Resolves a performance issue where `bulletRe`, `taskRe`, and `orderedRe`
were instantiated and compiled inside the `parseBlocks` function on every
call, causing unnecessary overhead during repeated markdown parsing (e.g.,
when chat messages stream in).
These regex objects have been hoisted to file-level `private val`
constants (`BULLET_RE`, `TASK_RE`, `ORDERED_RE`), ensuring they are compiled
only once when the class is loaded.
A benchmark `MarkdownTextBenchmark.kt` was added to verify the
performance change, showing an approximate 50ms improvement over 10k
iterations.
* 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>
Introduce ChatScrollController owning all chat scroll behavior:
- continuous bottom-follow intent tracked from LazyListState (not only on data change)
- pixel-precise bottom detection (last item edge vs viewport, small tolerance)
- streaming follow that pauses on upward scroll and never forces the reader down
- tail updates keyed on messages/streaming/thinking/subagent/clarify
- unread (new-message) count shown on the scroll-to-bottom FAB, cleared on tap
- paging anchor preserved by message id across older-history prepend
- all scroll commands (send/FAB/session switch/search/paging) serialized via one scope
Closes#682
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* perf(model): optimize model tab load speed with two-phase fetch and loadQuick
* perf: optimize data loading across Config, System, Profiles, and Billing tabs
* chore: remove leftover bottom nav references and unused updatePrimaryScreens
* style: fix ktlint violations in Config/Profiles/Billing ViewModels
- property/argument-list wrapping for async(...) calls
- multiline-expression wrapping for async blocks
- remove unused import in ConfigViewModel
- break long error-message string literals to stay under 120 cols
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
* ui: input bar reasoning chip tweaks
- Remove emoji from reasoning level labels (dropdown + chip text)
- Reduce reasoning chip horizontal padding (8dp -> 4dp)
- Move reasoning chip to right side (after spacer, next to mic)
- Increase model chip minimum width (120dp -> 140dp)
- Remove unused width import
* fix(ui): add missing widthIn import in ComposerToolbar
Resolves 'Unresolved reference widthIn' compile error at
ComposerToolbar.kt:95 that broke CI (unit-tests, android-lint,
instrumented-tests all fail on compileDebugKotlin).
* ui(chat): enhance input bar layout, reasoning menu header, and card shadow
Give ChatInputBar a floating rounded container with shadow contrast. Make model chip take available space and add header to reasoning menu.
* fix(ui): remove trailing blank line in ComposerToolbar for ktlint
Fix standard:no-consecutive-blank-lines ktlint violation.
* ui(model): enhance ModelPickerDialog with rounded cards and provider badges
Apply card styling, rounded corners (24dp dialog, 12dp cards), provider badges, and shadow contrast to ModelPickerDialog.
* feat(model): add in-dialog model pinning to ModelPickerDialog
Add pin/unpin icon buttons to model cards in ModelPickerDialog across both ModelScreen and ChatScreen.
* ui(model): remove provider badge chip and eliminate bottom whitespace in ModelPickerDialog
Replace bottom Cancel button with top-right Close icon to eliminate bottom whitespace. Remove inline provider badge chip from model cards.
* perf(model): optimize ModelPickerDialog load time with lazy items and O(1) set lookup
Lazy-register individual model items in LazyColumn and memoize pinnedSet to prevent synchronous rendering lag when opening dialog.
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
When the initial REST page returns empty messages, hasOlderMessages
was still set to true (offset > 0), causing an infinite loop of empty
load-more requests. Guard with && chatMessages.isNotEmpty().
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
* feat(chat): rich message bubbles with reasoning, code, clarify, subagent cards (#659#660#661#662#663#664)
* fix(chat): render ClarifyBubble at bottom of message list, not as inline SYSTEM message
Fixes two on-device bugs:
1. ClarifyBubble now renders at the very bottom of the LazyColumn
(after all messages, streaming message, typing indicator, and subagent
indicators) instead of inline among regular messages.
2. Bubble disappears when an option is selected or dismissed — the
ClarifyBubble is driven by state.clarifyRequest instead of a
persisted ChatMessage.clarifyInfo, so clearing the request state
removes it from the list entirely.
* fix(chat): add text input field to ClarifyBubble for custom typed answers
* fix(chat): render ReasoningCard at list level with stable key, not inside AssistantBubble
* fix(chat): address agy review — hoist regex, fix a11y, cache anim specs, disable animateContentSize while streaming
* fix(chat): replace hardcoded Color() with theme constants to pass CI checkColorLiterals
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
* feat(chat): add reasoningLevel to ChatUiState + ViewModel methods
* feat(chat): add ComposerToolbar with model + reasoning chips
* refactor(chat): rewrite ChatInputBar to two-row layout
* feat(chat): wire ComposerToolbar into ChatScreen
* fix(chat): make model chip fixed 120dp with horizontal scroll for long names
* feat(chat): reasoning chip opens dropdown menu with all 9 levels instead of cycling
* fix(chat): remove Auto level from reasoning menu — backend only supports none..ultra, default is medium
* feat(chat): sync model + reasoning level from backend session.resume response
* fix(chat): handle session.info events for model/reasoning sync + fix null value 4002
* fix(chat): safety timeout for createNewSession + try-catch event loop
* fix(chat): address agy review findings
- Restore InsertDriveFile icon for non-image attachments
- Add widthIn(max=120.dp) constraint on attachment filename
- Restore stringResource reference for remove-attachment description
- Remove dead cycleReasoningLevel() method (dropdown supersedes)
- Fix createNewSession timeout race with generation counter
* fix(chat): update instrumented test for two-row layout
chatScreen_renders_and_acceptsInput had an assertion that the mic
button disappears when text is entered — this was the OLD single-row
layout behavior. The new two-row composer always shows the mic button
in the toolbar row, independent of input state.
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
* fix(chat): make chat input composer more compact and flush
* fix(chat): fix chat page compose structure (issue #652)
- Resolve EmptyState rendering alongside empty LazyColumn by wrapping in if/else
- Flatten nested weight(1f) layout by moving search bar to ChatScreen
* style(chat): run ktlint auto-format and fix missing padding import
* style(chat): remove unused imports in ChatComposer and ChatMessageList
* fix: correct bulk session delete endpoint + surface deleted count
- Fix Retrofit @POST path from 'api/sessions/delete' to 'api/sessions/bulk-delete'
- Change response type from Response<Unit> to Response<BulkDeleteResponse>
- Add BulkDeleteResponse model (ok, deleted) matching backend contract
- Surface backend 'deleted' count in toast message instead of using client-side count
Backend contract (web_server.py:10750):
POST /api/sessions/bulk-delete
Body: { "ids": [...], "delete_all": false }
Response: { "ok": true, "deleted": <count> }
Fixes#651
* fix: replace client-side session list filter with full refresh after bulk delete
- Call loadSessions() + loadStats() after successful bulk delete instead of
filtering sessions/searchResults locally via 'ids' — fixes state desync
when backend deletes fewer sessions than requested (partial success) and
is correct for delete_all=true where ids may be empty
- Show 'No sessions were deleted' toast when deletedCount == 0
- Extract toastMsg to separate val to satisfy ktlint lint rules
Addresses review findings: UI state desync, delete_all handling, hardcoded
pluralizing, and zero-deletion UX.
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
* Fix#647: resolve Default profile URL from top-level login URL
ConnectionProfile.resolvedBaseUrl (used directly by ConnectionSection for
the profile list display) silently fell back to the hardcoded loopback
127.0.0.1:9119 whenever a profile's own baseUrl was null, instead of using
the URL the user actually authenticated against.
- Add ConnectionProfile.resolveBaseUrl(topLevelBaseUrl) that prefers the
profile's own baseUrl, then the top-level login URL, then legacy loopback.
- Store-level ServerCollectionOps.resolvedBaseUrl now applies the same
fallback so a null-baseUrl profile still shows the real gateway.
- ConnectionSection, SettingsViewModel and ConnectViewModel resolve against
AuthManager.getBaseUrl() so the loopback fallback can never surface.
- Add Issue647ProfileUrlTest covering login-stamp, null-baseUrl fallback,
own-URL precedence, fresh-install loopback, and store-level resolution.
- Update existing AuthManagerTest assertions to the new property form.
* Fix#647 (root cause): cache login URL at top-level baseUrl
The previous change only made profile resolution prefer the top-level
login URL, but setBaseUrl() never actually wrote the top-level baseUrl
field — so it stayed DEFAULT_BASE_URL (https://127.0.0.1:9119/) forever.
A Default profile seeded with the loopback default (or whose own baseUrl
was never stamped) therefore still displayed the loopback URL after
logging in via a custom LAN address.
setBaseUrl() now also stamps the top-level baseUrl, making it the
authoritative "last successful login URL" cache. getBaseUrl() and the
store-level resolvedBaseUrl (profile-list display) now reflect the URL
actually authenticated against.
Add regression tests mirroring the exact AuthLoginViewModel.connect()
sequence (seed with loopback -> setBaseUrl(loginUrl) -> token) asserting
the Default profile, top-level baseUrl and store display all resolve to
the login URL, never loopback.
---------
Co-authored-by: M57 <Hy4ri@users.noreply.github.com>