* fix(#575): drop description text from slash-command suggestions
* fix(#575): show /stop and /interrupt in slash-command menu
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix(#572): clarify Dismiss now informs agent instead of silently dropping
* fix(#572): clarify Dismiss rejects the question (no answer) instead of telling agent to proceed
---------
Co-authored-by: M57 <m57@hermes-control.app>
* feat(#538): wire tool.progress/generating + subagent.* events into chat
* fix(#538): remove subagent indicator on subagent.complete so it doesn't linger
* style(#538): drop dead Elvis on non-nullable event.text in onMessageComplete
---------
Co-authored-by: M57 <m57@hermes-control.app>
* feat(#533): add session.branch (fork a conversation) to mobile
The gateway already supports session.branch (tui_gateway/server.py), but
the mobile had no client surface. This adds:
- WsMethods.SESSION_BRANCH = "session.branch"
- A Branch (CallSplit) action on each session card that forks it via the
session.branch WS RPC with the source session_id (and optional name).
- branchedSessionId state -> SessionsScreen navigates to the freshly
forked session on success; RpcError -> toast; spinner while branching.
- SessionsViewModelTest (4 tests): forwards session_id, forwards name,
sets branchedSessionId on success, toast+no-branch on RpcError, and
consumeBranchedSession clears the pending id.
Verified against the live gateway contract: session.branch expects
{session_id, name?} and returns {session_id, title, parent}. Local
testDebugUnitTest: 4/4 pass, ktlint clean.
* refactor(#533): make /fork work via chat instead of a session-menu button
Per review feedback: the session.branch surface belongs in the chat
composer, not the session list menu.
- SlashCommandDispatcher: /fork and /branch -> new SlashResult.SessionBranch.
- ChatViewModel: handles SessionBranch by sending session.branch with the
active runtime session id (optional arg = branch title), and on the RPC
result switches the current session to the new forked one (mirrors the
SESSION_CREATE handler). This fixes /fork, which previously fell through to
command.dispatch and 4018'd (backend only knows session.branch).
- Removed the Branch (CallSplit) button + navigation LaunchedEffect from
SessionsScreen/SessionsViewModel (reverted those two files to main) and
dropped the now-unused sessions_action_branch string.
- Tests: deleted SessionsViewModelTest (branch-only); added /fork -> SessionBranch
to SlashCommandDispatcherTest and two ChatViewModel tests asserting /fork
sends session.branch with the session id + optional name.
Local testDebugUnitTest: SlashCommandDispatcherTest 8/8, ChatViewModelTest 50/50,
0 failures. ktlint clean.
---------
Co-authored-by: ronia <ronia@m57>
Mobile was silently dropping two event types the gateway emits. This adds
parsing + UI surfacing for both, bringing mobile to parity with desktop
(which shows gateway errors as red toasts).
- `error` (backend crash) -> WsEvent.GatewayError, surfaced in the existing
error banner (mirrors how RpcError is handled).
- `background.complete` (scheduled job finished) -> WsEvent.BackgroundComplete,
surfaced as a non-blocking snackbar in ChatScreen.
Changes:
- WsEvent.kt: add GatewayError(message?) + BackgroundComplete(data?) sealed subtypes.
- EventParser.kt: handle "error" + "background.complete"; unknowns still fall
through to Unknown (no behavior change for existing event types).
- ChatWsEventReducer.kt: onGatewayError (sets errorMessage, clears loading) +
onBackgroundComplete (sets backgroundCompleteMessage); mirrors onRpcError.
- ChatViewModel.kt: backgroundCompleteMessage state field, clearBackgroundComplete(),
handleWsEvent branches (ViewModel owns the snackbar trigger).
- ChatScreen.kt: pass backgroundCompleteMessage to ChatLifecycleEffects + a
LaunchedEffect that shows the snackbar and calls clearBackgroundComplete().
- EventParserTest.kt: cover both new events (incl. missing-message case).
Pure additive; sealed class + exhaustive when() preserved. Verified:
compileDebugKotlin + ktlintCheck green; EventParserTest passes.
Closes#527
Co-authored-by: M57 <m57@hermes-control.app>
* fix(ws): align RPC timeout to 120s + rejectAllPending on disconnect (fixes#526)
Mobile's pending-request handling diverged from the desktop contract
(apps/shared JsonRpcGatewayClient) in two ways that caused silent hangs:
1. Timeout was 30s while the desktop uses 120s (DEFAULT_REQUEST_TIMEOUT_MS),
so legitimately long agent turns were pruned early. Align to 120s and use
it for both the cleanup sweep and sendRpcAndAwait's per-call withTimeout.
2. The cleanup sweep only Log.w'd stale requests and dropped them — it never
failed the CompletableDeferred, so sendRpcAndAwait callers awaiting a
server that never answered HUNG FOREVER. And on disconnect/reconnect there
was NO rejectAllPending, so in-flight awaited RPCs also hung until their
own timeout.
Changes (all in ChatViewModel.kt):
- cleanup sweep now completeExceptionally()s the deferred AND surfaces a
'Request timed out' error for fire-and-forget RPCs (was silently dropped)
- new rejectAllPending() fails + clears every in-flight pending deferred;
wired into the disconnect-status collector and reconnect()
- PENDING_REQUEST_TIMEOUT_MS = 120_000 (matches desktop); sendRpcAndAwait
uses it instead of a hardcoded 30s
Tests: 2 new ChatViewModelTest cases cover rejectAllPending on disconnect
and on reconnect (no hang, VM stays usable). (Timeout-surfacing path shares
the deferred-fail logic with handleRpcError; the 120s sweep can't run under
the unit-test dispatcher's periodic-timer limitation, noted in PR.)
Mirrors the desktop-app audit in /opt/hermes-agent.
* fix(ws): aggregate pending-request cleanup into a single UI update (PR #541)
Sourcery review (PR #541): startPendingRequestCleanup emitted one
_uiState.update{} per stale request inside the loop, causing N separate
emissions / redundant recompositions when many RPCs time out at once.
Collect fire-and-forget methods and emit a single combined errorMessage
update after the loop.
rejectAllPending is intentionally left silent on disconnect/reconnect:
those are transient socket events (the RPC often completes on the new
socket), so surfacing a scary error banner is UX noise. Only the
timeout path surfaces errors — that is a genuine failure. This matches
the desktop JsonRpcGatewayClient.rejectAllPending semantics.
* refactor(ws): move awaited-RPC pending/timeout layer into HermesWsClient (closes#526)
Issue #526: port the JsonRpcGatewayClient.request() pattern into
HermesWsClient. HermesWsClient now owns the in-flight map, per-call
120s timeout, resolve-on-RpcResult/RpcError, and rejectAllPending on
disconnect/reconnect. ChatViewModel.sendRpcAndAwait simply delegates
to wsClient.request(...).await(); its local pendingRequests map,
PendingRequest, startPendingRequestCleanup loop and rejectAllPending
are removed. Fire-and-forget send() is unchanged.
---------
Co-authored-by: M57 <m57@hermes-control.app>
Previously mobile dropped the gateway's sudo.request and secret.request
events, so any agent turn needing a sudo password or a secret value hung
forever with no UI. Now:
- EventParser maps sudo.request / secret.request -> WsEvent.SudoRequest / SecretRequest
- ChatViewModel surfaces a secure dialog (SudoPromptUi / SecretPromptUi) and
replies via sudo.respond / secret.respond with session_id + request_id + payload
- ChatWsEventReducer explicitly branches the new events (no-op, VM-owned)
- ChatScreen renders password-masked SudoPromptDialog / SecretPromptDialog
- 6 unit tests cover prompt-state, RPC params, and prompt-clearing
Mirrors desktop/web contract (apps/shared GatewayEventName + tui_gateway).
Derives from audit of /opt/hermes-agent desktop app.
The parseToolOutput function in ChatBubble.kt was handling the specific
parsing and formatting logic for the "todo" tool inline, making the
function long and harder to read.
This commit extracts the todo-specific formatting logic into a new
private function formatTodoToolOutput, improving the readability and
maintainability of ChatBubble.kt without changing any behavior.
* refactor(settings): remove current connection editor fields
Remove the active host, port, token fields, and clear token button from the main Connection tab, as profiles are now managed exclusively through the ProfileEditorDialog.
* feat(settings): migrate to full auto-save design and remove save button
Update settings change listeners (auto-reconnect, typing effect delay/toggle) to save state immediately to AuthManager. Remove the manual Save button from the scaffold's top bar and delete the redundant SaveIndicator.
* style(settings): clean up unused imports from settings UI cleanup
* fix(chat): resolve compose resource lint error and initial state test failure
Pre-resolve empty credentials string resource at the composable scope to fix LocalContext resource query lint error. Update testInitialStateAndConnection to use DISCONNECTED state to trigger connection and pass verification.
* fix(ws): stop chat opening from racing the singleton into a stuck RECONNECTING
Root cause: HermesWsClient is a global singleton connected once at login;
every tab observes its single connectionStatus. ChatViewModel is recreated
on every chat-tab open and its init calls connectWebSocket() -> wsClient.connect().
connect() only guarded the CONNECTED case, so when the socket had dropped
(singleton in RECONNECTING with its own scheduled reconnect) opening chat
spawned a SECOND socket on the same webSocket field. The orphaned socket's
later onClosed set connected=false + re-armed RECONNECTING after the new
socket connected, and currentBackoff kept resetting on each chat open,
leaving the status stuck on RECONNECTING while every other tab (which never
calls connect()) stayed healthy.
Fix:
- HermesWsClient.connect() now no-ops while CONNECTING/RECONNECTING (in-flight
socket already pending) and while AUTH_EXPIRED (needs re-auth, not a loop).
- ChatViewModel.connectWebSocket() mirrors the same terminal-state guard so
the chat tab never disturbs an already-working or already-recovering
connection. Behavior-preserving: happy path (CONNECTED) and genuine
terminal states (DISCONNECTED/NO_NETWORK) still connect as before.
* fix(#470): sync cookie store scope with selected profile ID
Update AuthManager to switch CookieManager to the active server profile ID on startup and on runtime profile change.
* fix(#470): prevent stale token cache and load initial cookie scope
Pass active profile scope to CookieManager.initialize on startup and update setProfileToken to sync the in-memory cachedToken.
* feat(chat): add re-login action and credentials popup to connection banner
Add a Re-login button to the connection status banner and a dialog to prompt for credentials, allowing the user to refresh session cookies/tokens and reconnect the WebSocket.
* fix(chat): address PR #495 review feedback
* fix(chat): restore reconnect action in banner
* fix: resolve composable invocation in ReloginDialog
* fix(chat): enable re-login action during reconnecting state
Add the Re-login button to the connection status banner when connectionStatus is RECONNECTING, allowing users to re-authenticate and resolve stuck reconnect loops.
* fix(chat): surface all disconnected states in reconnect banner (issue #491)
The connection-status banner only appeared for RECONNECTING/DISCONNECTED,
so NO_NETWORK and AUTH_EXPIRED left the user staring at a silent chat with
no feedback and no way to recover — the core of the 'reconnecting is not
user-friendly' complaint.
ChatTopBanner now shows for every non-connected, non-connecting state:
- RECONNECTING: spinner + 'Reconnecting to Hermes…' (auto, no button)
- DISCONNECTED: 'Disconnected from Hermes' + Reconnect
- NO_NETWORK: 'No network connection' + Reconnect (force a retry)
- AUTH_EXPIRED: 'Session expired' + 'Sign in again' -> AuthLoginScreen
The auto-reconnect engine (backoff, network-return trigger, in-flight
message queue in HermesWsClient) is untouched — this only makes the stuck
states visible and actionable.
* fix(chat): use LocalHermesStatusColors for reconnect banner (review fix)
Per AGENTS.md, semantic status colors must come from
LocalHermesStatusColors, never MaterialTheme.colorScheme. The reconnect
banner used errorContainer/onErrorContainer directly, which is both a
semantic-color violation and inconsistent with the rest of the app
(Navigation.kt maps DISCONNECTED/NO_NETWORK/AUTH_EXPIRED -> .error;
TelegramOnboardingDialog uses .errorContainer for error surfaces).
Switch Surface bg to errorContainer and foreground (spinner, text,
button content) to error via LocalHermesStatusColors.current.
* fix(chat): make search bar clickable by giving ChatMessageList a vertical layout
The search bar (AnimatedVisibility) and the message LazyColumn were
emitted as siblings with no container. The call site is a Box, which
overlays children — so the LazyColumn rendered on top of the search bar,
intercepting every pointer event. The bar stayed visible but was dead
(nothing inside clickable: input, nav arrows, close).
Wrap ChatMessageList's children in a Column so the bar occupies real
layout space above the list; the LazyColumn now takes weight(1f) and
scrolls independently. Fixes#490.
* refactor(chat): simplify search-list LazyColumn modifier
Use fillMaxWidth().weight(1f) instead of fillMaxSize().weight(1f) inside
the new Column — weight already claims the remaining vertical space, so
fillMaxSize was redundant/contradictory (Sourcery review nit).
Note: the Sourcery 'use LocalHermesStatusColors' suggestion on the search
bar Surface is a false positive — that rule covers SEMANTIC status colors
(success/warning/error/info) only. surfaceContainerLow/outline are neutral
Material surface chrome, not status colors; LocalHermesStatusColors does
not even expose them. The corresponding status dot correctly uses StatusRed.
* refactor(chat): extract streaming state into ChatStreamingController
Moves streaming buffers, streamingMessageId, and the two token/delta
handlers out of ChatViewModel into a dedicated controller. Mirrors the
ChatSearchDelegate seam; _streamingState stays VM-owned and the WS
reducer is untouched. Behavior-preserving, ChatScreen unchanged.
* refactor(chat): drop write-only streamingMessageId in ChatStreamingController
Sourcery flagged streamingMessageId as write-only. Verified via git grep
that it was write-only in origin/main too (zero readers anywhere in the
repo), so removing it is behavior-preserving. beginStreamingMessage() now
delegates its buffer reset to resetStreaming() to remove the duplication
Sourcery noted. Kept the injected scope param — it mirrors the
ChatSearchDelegate seam (that delegate uses scope.launch) and keeps the
controller constructor shape consistent.
* refactor(chat): extract ChatSearchDelegate from ChatViewModel god-object
Move the 4 search methods (toggleSearch/setSearchQuery/navigateSearchMatch/
clearSearch) + searchJob field into a dedicated ChatSearchDelegate that owns the
chat-search state transitions against the shared _uiState flow. ChatViewModel
keeps thin one-line delegating wrappers so its public API (used by ChatScreen)
is unchanged.
This is the first separable slice of the ChatViewModel god-object (1421 LOC,
47 funcs). Streaming/session/WS clusters are intentionally left in place - they
are entangled and will be addressed separately to avoid splitting one logical
op across classes.
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
* refactor(chat): extract ChatAttachmentsDelegate from ChatViewModel
Move addAttachment/removeAttachment/clearAttachments into a dedicated
ChatAttachmentsDelegate that owns pendingAttachments state transitions against
the shared _uiState flow. ChatViewModel keeps 3 one-line delegating wrappers -
public API unchanged. Read sites (sendMessage) already read pendingAttachments
straight off uiState, so no delegate read API needed.
Second separable slice of the ChatViewModel god-object (after #486 search).
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
* refactor(chat): address Sourcery review on search extraction
- Inject the CoroutineDispatcher into ChatSearchDelegate instead of
hardcoding Dispatchers.Default (defaults to Dispatchers.Default so
behavior is unchanged; now customizable per environment).
- Group the four search delegating methods in ChatViewModel under a
'Compatibility façade' comment marking them as the transitional API
surface around ChatSearchDelegate.
- ChatViewModel: drop fetchAndDisplayStatus/Sessions/Stats — left behind
after slash-command dispatch rework (e7b2973c); /status /sessions /stats
now route to backend via SlashCommandDispatcher.RpcDispatch -> WS COMMAND_DISPATCH.
Zero call sites, grep-confirmed.
- AuthManager: drop KEY_SELECTED_PROFILE_ID const — selected profile id
moved to ServerStore StateFlow (getSelectedProfileId/setSelectedProfileId).
Legacy PREFS key never read.
- Theme: drop ENABLE_DYNAMIC_COLOR const — theming reads useDynamicColors
setting (Theme.kt:318), not this const. Dead since that refactor.
Verification: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
Per behavior-preserving-cleanup: grep zero refs, not exported, no
dynamic/reflection access, git blame shows original intent superseded.
* refactor: migrate Gson serialization to kotlinx.serialization
* fix(dto): resolve serializing Any? and Map<String, Any?> with JsonElement
* test(refactor): fix unit test issues under kotlinx.serialization and update nullable DTO defaults
* fix(chat): support flexible numeric and string decoding for message timestamps
* fix(chat): sequentialize cache loading to avoid overwriting network state
* fix(#472): refactor ChatViewModelTest to use Fake* + Turbine
Replace Mockk-heavy test setup with FakeChatPersistenceRepository (+
in-memory FakeChatMessageDao) and add Turbine for Flow assertions.
Changes:
- Add Turbine v1.2.0 dependency (libs.versions.toml + build.gradle.kts)
- Create FakeChatMessageDao – in-memory DAO with MutableStateFlow
- Create FakeChatPersistenceRepository – delegates to FakeDao
- ChatViewModel: make repo injectable (@VisibleForTesting lateinit var)
- Rewrite ChatViewModelTest (751 lines vs 1,212): clearer helpers,
Turbine test for streaming Flow sequence, same 29 tests with
identical assertions, ~38% line reduction
Test: ktlint --format passes clean
* fix(#472): address review — constructor injection, ConcurrentHashMap, MockWebServer tests
Addresses all feedback from Sourcery review on PR #474:
Constructor injection (was lateinit var):
- Move repo to ChatViewModel primary constructor with default
- Remove @VisibleForTesting lateinit var + init block assignment
- Tests pass repo directly via constructor
Simplify FakeChatMessageDao:
- Replace manual synchronized(lock) with ConcurrentHashMap
- Same thread safety, 48% less code, no boilerplate lock object
New MockWebServer tests:
- HermesApiServiceMockWebServerTest: 6 tests covering getStatus,
getSessions, getSessionMessages + edge cases (null fields,
empty lists, slash-encoded session IDs)
* fix: CI compilation errors — open class, TestScope receiver, runBlocking
Three compilation errors blocking CI:
- ChatPersistenceRepository was final, but FakeChatPersistenceRepository
extends it → make it open class
- createViewModelWithSession() called advanceUntilIdle (TestScope ext)
and emit() (suspend) without a TestScope receiver or coroutine ctx →
make it fun TestScope.xxx() and use tryEmit() for non-suspend emission
- HermesApiServiceMockWebServerTest called suspend API methods from
regular @Test methods → wrap each test in runBlocking
* fix: test runtime failures — suspend helper + fix JSON escaping
- Use so emit()
(suspend) is valid and events are delivered synchronously instead of
buffered via tryEmit() which was causing 17 test failures
- Fix MalformedJsonException in MockWebServer test by using plain string
content instead of unescaped JSON-within-JSON
* fix: correct req-id values in ChatViewModelTest — SESSION_CREATE is req-id-3
* fix(test): correct ChatViewModelTest assertions for Fakes+Turbine refactor
- testMessageStreamingFlow: collect streamingState via Turbine
(ThinkingDelta/MessageToken mutate streamingState, not uiState)
- testToolExecution_finalizesPreviousStreamingMessage:
account for messages[0] = "Session created" system message
- testMessageStart_finalizesPreviousStreamingMessage:
same index offset (messages[1] is the finalized stream)
- testClarifyRequestCustomResponse: options is emptyList(), not null;
message count is 3 (session-create + user clarify reply)
Fixes CI failures introduced in PR #474.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#434): add system tab model files
* feat(#434): add system API endpoints
* feat(#434): rewrite SystemViewModel with full parity
* feat(#434): add system screen UI and string resources
* feat(#434): update tests for new SystemStatsResponse & ViewModel
* fix(#434): fix compile errors — nullable Boolean, SmallFilledTonalButton -> FilledTonalButton
* fix(#434): fix old SystemStatsResponse refs in ChatViewModel, wrap sections in item{} blocks
* fix(#434): rename sections to lowercase, fix LazyListScope extensions
* fix(#434): wrap hook dialog in item{}, pass spacing param to actionLogSection
* fix(#434): update HermesApiServiceTest for new typed SystemStatsResponse
* fix(#434): fix E2E test coroutine timing, remove outdated Gson edge-case test
* fix(#434): fix JUnit 4 assertNotNull argument order
* fix(#434): simplify HermesApiServiceTest, restore all mocks in E2e test
* fix(#434): remove Gson-edge-case test, fix E2E test dispatcher wiring
- Removed testGetSystemStats_nonNumericPercent_returnsNull: tests Gson
type-coercion behavior, not app logic — throws JsonSyntaxException
- Fixed E2eIntegrationTest dispatcher: mock Dispatchers.IO with answers {}
that returns the current Dispatchers.Main dynamically, so runTest's
per-test dispatcher override is honored by both viewModelScope.launch
and async(Dispatchers.IO) blocks → advanceUntilIdle() advances all work
- Removed Dispatchers.Main static mock so runTest can override it
* fix(#434): fix E2E test dispatcher for SystemViewModel
- Removed Dispatchers.setMain + testDispatcher field from setUp
- runTest handles Dispatchers.Main automatically — don't override it
- Mock Dispatchers.IO with answers { Dispatchers.Main } so it
dynamically resolves to whatever Main is at call time (runTest's
TestScope dispatcher)
- This ensures viewModelScope.launch and async(Dispatchers.IO) both
run on the same TestScope dispatcher, making advanceUntilIdle()
effective for all work
* fix(#434): use runTest(testDispatcher) for SystemViewModel test
The SystemViewModel.loadAll() uses async(Dispatchers.IO) inside
coroutineScope { }. With the mocked Dispatchers setup, both Main and
IO return testDispatcher, but runTest()'s default TestScope uses a
different dispatcher instance whose advanceUntilIdle() can't advance
testDispatcher's scheduler.
Fix: pass testDispatcher to runTest() so the TestScope's scheduler
matches testDispatcher's scheduler, making advanceUntilIdle()
effective for all async work.
* fix(#434): align test assertion with toast message change
The SystemViewModel.triggerBackup() toast message was changed from
'Backup triggered successfully' to 'Backup triggered', but the
E2eIntegrationTest assertion wasn't updated, causing a
ComparisonFailure on the unit-tests CI job.
* fix(#434): address review issues
- Fix#1: Change triggerBackup() return type from Unit to ActionResponse
so backupArchive is captured, enabling the download button
- Fix#2: Replace deprecated LocalClipboardManager with LocalClipboard
suspend API (removes CI deprecation warning)
- Fix#3: Consolidate redundant 'Hermes version' InfoRow in gateway
section (sessions + PID shown in one row)
- Remove orphaned system_gateway_state_pid string resource
- Fix import ordering for ktlint compliance
* fix(#434): revert LocalClipboard change, use @Suppress('DEPRECATION') instead
LocalClipboard.setText is not available in the Compose version used
by the project. Reverting to LocalClipboardManager with a suppress
annotation to keep the deprecation warning clean without breaking
compilation.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes@agent.local>
Changes message bubbles (user + assistant) so the copy button appears
on a single tap rather than requiring a long-hold gesture.
- UserBubble: combinedClickable → clickable with showCopyButton
- AssistantBubble: combinedClickable → clickable with showCopyButton
- Removed unused combinedClickable import + ExperimentalFoundationApi opt-in
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* fix: clear loading state on connection failure or status change
Observe connectionStatus in ChatViewModel to hide the loading overlay on disconnect/reconnect/error, and add a 10s fallback timeout.
* test(chat): fix testInitialStateAndConnection regression
Mock Log and isAutoReconnect in ChatViewModelTest setUp, and only run the connection safety timeout outside test environments.
* feat(chat): implement optimistic UI for chat startup loading
Load the last active session ID and cached messages instantly from local database, and resume in the background.
* feat(chat): always start a fresh session on startup without spinner
Configure startup to connect and create a new session in the background without showing any blocking loading spinners.
* fix(chat): support session creation when WebSocket is already open on launch
Check if WebSocket is already connected during initialization, and trigger initial setup/session creation immediately.
* refactor(chat): deduplicate gateway ready initialization logic
Extract handleGatewayReady helper function to run the session setup once on both new and pre-existing WebSocket connections.
* chore: clean up dead lastSessionId storage code
Remove getLastSessionId, setLastSessionId, and KEY_LAST_SESSION_ID as startup session caching is no longer used.
* feat(#195): add chat attachment support for files and images
- Add Attachment data model with computed properties (isImage, formattedSize, fileExtension)
- Extend ChatMessage with optional attachments list
- Add Coil 2.7.0 for async image loading in Compose
- Extend ChatViewModel with pending attachment state, addAttachment/removeAttachment/clearAttachments
- Build attachment-prefixed message text (data URIs for images, file refs for others)
- Add paperclip button and file picker launcher (ActivityResultContracts.GetContent) to ChatScreen
- Add animated attachment preview chips with thumbnails before sending
- Render attachments inline in UserBubble (AsyncImage for images, file cards for others)
- Add string resources for attachment UI features
* fix(#195): rewrite attachment upload protocol to match Hermes backend RPC methods
BREAKS: removes old buildAttachmentText() that embedded base64 data URIs in prompt text.
Changes:
- Replace inline data-URI encoding with proper backend RPC flow:
- image.attach_bytes for images (fire-and-forget, auto-queues to session)
- file.attach for non-image files (awaited, returns @file: ref text)
- Add CompletableDeferred to PendingRequest so sendMessage can await RPC responses
- Add readContentUriBytes() to read file content from content:// URIs
- Add sendRpcAndAwait() suspend function for sequential RPC calls
- Add RpcCallException for awaited RPC error handling
- Suppress error UI for awaited RPCs (caller handles them directly)
- Keep ChatScreen/ChatBubble UI layer unchanged (file picker, previews, inline images)
* feat(#195): add camera capture support via FileProvider + TakePicture contract
- Creates temporary photo files in cacheDir via FileProvider
- Launches system camera with ActivityResultContracts.TakePicture
- Adds camera icon button (📷) next to the paperclip in ChatInputBar
- Captured photos added as image/jpeg attachments via same addAttachment flow
- FileProvider configured with cache-root path in res/xml/file_paths.xml
- Error handling for launch failures and capture failures with snackbar
* refactor(#195): group camera/image/file into single attach dropdown
- Replaces separate 📷 and 📎 buttons with a single 📎 button
- DropdownMenu opens with three options:
- 📷 Camera → TakePicture via FileProvider temp URI
- 🖼️ Image → GetContent("image/*")
- 📄 File → GetContent("*/*")
- Removes onAttachClick, adds onImageTap/onFileTap callbacks
- Input bar stays clean with one attachment affordance
* fix(#195): add missing imports for CI compilation
ChatScreen.kt:
- android.util.Log
- androidx.compose.animation.core.* (infiniteRepeatable, tween, LinearEasing, RepeatMode, rememberInfiniteTransition)
- androidx.compose.foundation.layout.widthIn
- androidx.compose.material.icons.filled.AttachFile
- androidx.compose.material.icons.filled.InsertDriveFile
- androidx.compose.ui.layout.ContentScale
- coil.compose.AsyncImage
ChatBubble.kt:
- androidx.compose.material.icons.filled.InsertDriveFile
* fix(#195): resolve android-lint LocalContextGetResourceValueCall error
Replace context.getString(R.string.chat_camera_error) with a
composable-level cameraErrorMsg captured via stringResource()
to avoid querying resources via LocalContext.current in a callback.
* fix(#195): migrate InsertDriveFile to AutoMirrored, fix chip double-remove
- Replace deprecated Icons.Filled.InsertDriveFile with
Icons.AutoMirrored.Filled.InsertDriveFile in ChatBubble.kt
and ChatScreen.kt
- Remove onClick from AttachmentChip Surface so only the
dedicated close button triggers removal, not accidental taps
on the chip body
---------
Co-authored-by: Hermes Agent <hermes@agent.local>
Prevents the 404 error banner that briefly flashes when switching back
to the Chat tab. The WebSocket keeps the UI current, so the REST call
is only needed on initial session load.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat: clean todo tool display in chat bubbles
Add todo-specific parsing in parseToolOutput that extracts the
structured todos array and summary counts from the gateway payload,
building a clean one-line summary and formatted item list with
status markers ([x], [>], [~], [ ]).
Also register 'todo' in ToolSchemaRegistry with 📋 emoji.
Previously the todo tool fell back to generic rendering, showing the
raw todos/summary JSON as ugly key-value pairs in the expanded view.
* feat: clean fact_store tool display in chat bubbles
Add fact_store-specific parsing in parseToolOutput that handles all
action types: probe/search/reason/related/contradict (results[] array),
list (facts[] array), and simple CRUD status (add/update/remove).
Each fact is rendered as a compact block:
#id content text
[category] trust: 0.50 🏷️ tags
Summary line shows count + action context (e.g. '4 facts (probe: entity)').
Also register 'fact_store' in ToolSchemaRegistry with 🧠 emoji.
* feat: clean session_search tool display in chat bubbles
Add session_search-specific parsing in parseToolOutput that handles all 4
calling modes: discover (results[] with title/snippet/bookends), scroll
(messages[] with anchor marker), read (messages[] with session dump and
truncation flag), and browse (results[] with recent session metadata).
- Registered in ToolSchemaRegistry with U+1F50D (magnifying glass) emoji
- Summary adapts per mode: "U+1F50D 3 sessions: query" / "U+1F4DC N messages (scroll)" / "U+1F4D6 N messages" / "U+1F4CB N recent sessions"
- Results show date, source, title, model, matched role, snippet
- Messages show role emoji (U+1F464/U+1F916/U+1F527), id, tool name, content
- Anchor message gets U+2B05 marker in scroll mode
- Content capped at 300 chars, snippets at 200, newlines flattened
- Existing todo and fact_store formatting unchanged
* feat: add registry entries + custom parsing for all remaining tools
Registry entries (emoji+summaryArgKey) for 22 tools:
cronjob, memory, fact_feedback, process, skill_manage,
web_extract, browser_type, browser_cdp, browser_dialog,
vision_analyze, tts, video_generate, image_generate,
x_search, send_message, skills_list, skill_view,
tool_search, tool_describe, tool_call,
read_terminal, computer_use
Custom formatting blocks (10 tools):
memory (modes+status), cronjob (list+CRUD),
web_search (results with title/url/desc),
skills_list (skill cards), skill_view (content+files),
process (list+poll), x_search (answer+citations+degraded),
vision_analyze (description), web_extract (content),
tool_search (matches)
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#216): dynamic command autocomplete from server catalog, remove stop button
- Replace hardcoded 8-command autocomplete with dynamic catalog from server's commands.catalog RPC
- Show ALL commands including skills via commandCatalog.pairs
- Remove physical Stop button (use /stop or /interrupt slash commands instead)
- Remove onInterrupt callback from ChatInputBar
* fix(#216): hide /stop and /interrupt from slash suggestions, remove stale strings
- Filters /stop and /interrupt from autocomplete popup while keeping
them functional when manually typed
- Drops 7 unused command description string resources (now from server
catalog) plus unused interrupt string resource
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Root cause: when MessageComplete fires, the reducer atomically moves the
streaming message to the messages list and clears streamingMessage. The
messages list's typing effect condition (lastAnimatedMessageId check)
didn't account for the fact that this message was already rendered with
a typing effect in the streaming slot. The animation replayed from
scratch on the already-complete message, causing a visible blink.
Fix: add message.isStreaming to the condition. Messages finalized via
MessageComplete/MessageDone always have isStreaming=false, so they skip
the replay naturally. The streaming slot's StreamingBubbleWithTypingEffect
continues to handle the animation during active streaming.
Closes#374
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#210): add ToolSchemaRegistry with display config for each tool
* feat(#210): three-state ToolBubble with summary line, clean expanded view, copy support
* test(#210): add ToolBubbleParsingTest for schema-aware parsing
* fix(#210): CopyButton align modifier moved to call sites
* fix(#210): null-safety on durationSec in test
* fix(#210): backward compat for old session tool data formats
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>