* 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>
- SlashCommandDispatcherTest: covers all branch routes (Interrupt /
NewSession / RpcDispatch) incl. case-insensitivity, args, slash-only,
empty, and unknown commands.
- SlashCommandDispatchRpcTest: asserts a non-hardcoded command is
forwarded to COMMAND_DISPATCH with the exact {name,arg,session_id}
shape the gateway expects, args split correctly, and that client-
handled /stop + /new do NOT hit the backend.
Verified against the live hermes-agent gateway: commands.catalog returns
185 catalog entries but command.dispatch only handles a subset, so most
forwarded commands currently 4018. These tests lock the client contract;
the backend gap is tracked separately.
Co-authored-by: M57 <m57@hermes-control.app>
* fix(#548): stop System tab button text truncation + fix two wrong labels
* fix(system): remove inline icons from narrow system action buttons to prevent text cutoff
* fix(system): restore action icons and implement dynamic font scaling for button text
* fix: stack icon above text in system buttons to prevent label clipping
Replace horizontal Icon+Text layout with vertical Column stacking
(icon on top, text centered below) — the same pattern Material 3
NavigationBar items use. Gives text the full button width so labels
like 'Security Audit' and 'Config Migrate' never get cut off.
* fix: add missing ImageVector import for system buttons fix
---------
Co-authored-by: M57 <m57@hermes-control.app>
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>
* fix(network): scope management REST calls with ?profile= (fixes#528)
Mobile fired all management REST calls with NO profile scope, so every
profile read/wrote the backend's implicit default profile — multi-profile
setups silently corrupted config/skills/toolsets/mcp/model/env.
Add ProfileScopeInterceptor (OkHttp) that appends ?profile=<activeProfile>
to the same endpoint families the desktop/web dashboard scopes
(web/src/lib/api.ts PROFILE_SCOPED_PREFIXES), respecting:
- no active profile -> pass through (legacy default behavior)
- explicit ?profile= on the URL wins over the global scope
- only scoped families rewritten; ops/pairing/cron/profiles stay global
Wired into ApiClient.buildService(). 5 MockWebServer tests cover scoped
append, explicit-param precedence, no-profile passthrough, non-scoped skip,
and skills scoping.
Mirrors desktop/web contract derived from audit of /opt/hermes-agent.
* fix(ci): stub getSelectedProfileId in ApiClientTest + harden prefix match (fixes PR #540)
- ApiClientTest: stub AuthManager.getSelectedProfileId() to null. The new
ProfileScopeInterceptor (registered in ApiClient.buildService) calls it, and
the unstubbed real method threw IllegalStateException at AuthManager.kt:56,
breaking all 4 auth-interceptor tests.
- ProfileScopeInterceptor: replace startsWith(prefix) with whole-segment match
(path == prefix || path.startsWith(prefix/)) so lookalike paths like
/api/statusXYZ are NOT wrongly scoped (Sourcery review, PR #540).
- ProfileScopeInterceptorTest: add lookalikePath_notScoped + scopedSubPath_isScoped
to lock the segment-boundary fix.
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.
Explicit IO context for DB/WS/notify path. replyScope already uses
Dispatchers.IO; this makes the offload local and explicit. Keep return@withContext
only because nothing follows the withContext block today.
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.
Appends testToggleSearch verifying toggleSearch() activates search,
setSearchQuery sets the query, and toggling off resets searchQuery to empty.
Rebased onto current main (post-#511/#512).
* Test PairingCodeEntryViewModel onManualCodeChange
Added unit tests to cover the `onManualCodeChange` function in `PairingCodeEntryViewModel`.
The test validates that the function properly updates the state and handles string trimming
as well as error message clearance.
Changes include:
- Created `PairingCodeEntryViewModelTest.kt` in `app/src/test/java/com/m57/hermescontrol/ui/pairing/`
- Implemented `testOnManualCodeChange_updatesStateAndClearsError` to check correct state update logic and verify error clear functionality.
- Implemented `testOnManualCodeChange_trimsInput` to verify input is properly trimmed.
- Added necessary mock setup to deal with Android components (`Application` and `android.util.Log`).
* Fix ktlint format issue in PairingCodeEntryViewModelTest
Ran `ktlint --format` to fix the `standard:no-empty-first-line-in-class-body`
formatting violation introduced in the previous commit.
* Add unit tests for ChatViewModel attachment manipulation
Adds tests covering addAttachment, removeAttachment (valid/invalid indices),
and clearAttachments in ChatViewModelTest. This ensures the attachment
state management logic works as expected.
* test(#511): add mixed valid/invalid remove sequence + DRY attachment setup
Folds in both Sourcery review notes:
- New testRemoveAttachment_mixedValidAndInvalidSequence asserts list
stays consistent across a valid remove, out-of-bounds + negative no-ops,
then another valid remove on the shifted list.
- addDummyAttachments() helper removes repeated VM/advance/add boilerplate.
Verified: ktlintCheck clean + ChatViewModelTest 37/37 pass locally.
Refactored the deeply nested `parseDaysOfWeek` function using guard clauses to flatten the logic structure. By returning early on invalid conditions (`dowPart == "*" || domPart != "*" || monthPart != "*"` and `hoursList.size != 1 || minutesList.size != 1`), the remaining function body's indentation is reduced by two levels.
This improves overall code readability and maintainability without altering existing functionality. The change was verified against existing unit tests, which all pass.
Added SystemViewModelTest to provide basic testing coverage for the
openUpdateConfirm and closeUpdateConfirm methods. These methods
handle state changes within the UI without side-effects or
dependencies, so straightforward property assertions were sufficient.
This improves overall code reliability by ensuring simple UI boolean
states function predictably.
Add unit tests to `ChatViewModelTest.kt` to cover the `interruptSession`
method. The tests verify that `interruptSession` correctly sends the
`SESSION_INTERRUPT` RPC via the WebSocket client when a session ID is
present, and that it safely does nothing when the session ID is missing.
This improves test coverage for the chat control logic.
Added unit tests to `KeysViewModelTest.kt` to cover `setNewKeyName` and `setNewKeyValue` functions. This ensures that the state flow correctly updates when key names and values are modified. This increases the test coverage for UI state manipulation within the Keys settings module.
* Add unit test for ChatViewModel.refreshSettings
This commit adds a missing test case for the `refreshSettings` function in `ChatViewModel.kt`. The new test verifies that the UI state correctly updates to reflect changes in `AuthManager` properties such as `typingEffectEnabled` and `typingEffectDelayMs`. This resolves the testing gap and ensures safer refactoring for settings management in the future.
* test(#506): strengthen refreshSettings test to prove live re-read + drop test_plan.md
Re-stub AuthManager after construction and assert the state delta, so the
test actually catches a regression where refreshSettings() would cache the
init-time value instead of re-reading AuthManager live. Removes the one-off
Jules planning artifact from the repo root.
The hub card UI is rendered by the local private HubSkillCard in SkillsScreen.kt (not the unused components/HubSkillsTab.kt copy). Moved SourceBadge out of the top-right name row to the left of the Install button in the card footer (left-aligned source, right-aligned action), tying the skill origin (hub/clawhub/etc) to the install action.
* feat(#500): tap-to-reveal detail dialog for truncated cards
Add shared DetailDialog composable and wire tap-to-open full metadata
dialogs across Skills (installed + hub), MCP servers, Plugins, Toolsets,
and Webhooks screens. Action buttons stay on the card (sticky); tapping
the card body reveals untruncated description + key metadata.
Refs: #500
* fix(#500): resolve compile errors from missing runtime imports + dialog scope
- add missing androidx.compose.runtime.mutableStateOf/remember imports
in InstalledSkillsTab + HubSkillsTab (caused 'Unresolved reference'
cascade on compileDebugKotlin)
- move PluginsScreen detail dialog inside the composable function scope
(was nested in OrphanPluginCard -> showDetail unresolved)
- DetailDialog rows type: Pair<String?,String?> -> Pair<String,String?>
(key always non-null; fixes type-inference failure)
- drop unnecessary safe call on non-null label
Verified locally with the Android SDK:
./gradlew :app:compileDebugKotlin -> BUILD SUCCESSFUL
./gradlew :app:testDebugUnitTest -> BUILD SUCCESSFUL
Refs: #500
* refactor(#500): extract toDetailRows() mappers + semantic status tones; wire SkillsScreen hub browse
- add DetailRows.kt with @Composable extension mappers for Skill/HubSkill/
McpServer/PluginInfo/Toolset/WebhookSubscription -> List<DetailRow>
- DetailDialog now renders status rows via LocalHermesStatusColors (review #2):
SUCCESS/NEUTRAL/WARNING/ERROR/INFO tones instead of plain onSurfaceVariant
- wire SkillsScreen.HubBrowseView/HubSkillCard tap -> DetailDialog (same UX as
other screens) — user request
- remove JVM-signature-clashing Pair overload; drop unused Color import
- add detail_dialog_trust_level string (HubSkill has no version field)
- verified: compileDebugKotlin + testDebugUnitTest PASS locally w/ ANDROID_HOME
* fix(#500): full hub description in dialog + no name truncation
- HubBrowseView (both SkillsScreen + HubSkillsTab): on card tap, fetch
previewHubSkill(identifier) to get the FULL SkillContentResponse.content
(search snippet was truncated); dialog shows full content once loaded,
with a spinner while previewing. Falls back to snippet if no identifier.
- SkillsViewModel: add hubPreviewContent/isHubPreviewing/hubPreviewError
state + previewHubSkill()/clearHubPreview() (uses existing preview endpoint)
- DetailDialog: title now wraps fully (removed maxLines=2 ellipsis cap) so
long skill names are never cut off
- verified: compileDebugKotlin PASS + ktlint clean (local ANDROID_HOME)
- Replace machine-specific build instructions (/opt/android-sdk paths)
with generic gradle commands any contributor can use
- Remove Ronia-specific agent-author rule that would confuse external
contributors
- Remove kanban-internal bug annotation format from commit conventions
- Reword stale 'no SDK' caveat in Things to Avoid to match new build section
Backend commit 37a4cf900 (hermes-agent) flipped the default of
GET /api/model/options's include_unconfigured parameter from true
to false, so the mobile Models picker was silently regressing to
showing only configured/authenticated providers — the skeleton rows
for unconfigured providers (with setup-hint warnings) stopped arriving.
Add include_unconfigured as a @Query param defaulting to true so the
full provider universe is restored on the Models screen. Both current
call sites (ModelViewModel + ProfilesViewModel) invoke the API without
passing the new param, so they automatically pick up the true default.
* 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.
* refactor(ui): extract SkillsStep to components package
Move the 252-LOC SkillsStep composable out of ProfilesScreen.kt into
ui/profiles/components/SkillsStep.kt. Body is verbatim (behavior-preserving);
only package + imports changed. Parent now imports and calls it directly.
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
Note: sibling screens resolve Modifier.weight() without an explicit import;
matching that convention (no spurious weight import) to avoid the
internal RowColumnParentData.weight shadowing error.
* refactor(ui): extract ProfileBuilderView cluster to components package
Move the Profile Builder wizard (ProfileBuilderView + validateProfileName +
IdentityStep/ModelStep/McpStep/AddMcpDialog/ReviewStep) out of ProfilesScreen.kt
into ui/profiles/components/ProfileBuilderView.kt. Body is verbatim
(behavior-preserving); only package + imports changed.
Notable wiring required:
- validateProfileName kept as internal fun: it is also used by the parent's
clone dialog, so both the screen and the builder share it via import.
- ProfilesUiState/ProfilesViewModel imported from the ui.profiles package.
- SkillsStep call resolves to the already-extracted sibling file (same package).
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
* refactor(ui): extract MoaConfigDialog cluster to components package
Move the Mixture-of-Agents config dialog (MoaConfigDialog + MoaConfigEditor +
MoaSlotPicker + MoaPickerTarget sealed class) out of ModelScreen.kt into
ui/model/components/MoaConfigDialog.kt. Body is verbatim (behavior-preserving);
only package + imports + entry visibility changed.
- Entry MoaConfigDialog is internal (called by ModelScreen root); the editor,
slot picker, and sealed class stay private to the new file.
- The @Composable annotation preceding the declaration is moved with the
cluster (earlier attempt left it orphaned, causing a duplicate-annotation
compile error at the next declaration).
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
* refactor(ui): extract PlatformCard + TelegramOnboardingDialog clusters
Move two self-contained clusters out of ChannelsScreen.kt:
- PlatformCard.kt: StateStyle (private data class) + platformStateStyle +
StateBadge + PlatformCard. Entry PlatformCard is internal (called by
ChannelsScreen root); StateStyle/StateBadge/platformStateStyle stay private.
- TelegramOnboardingDialog.kt: TelegramOnboardingDialog. Entry internal.
Wiring required:
- ConfigureForm kept in ChannelsScreen as internal (shared by PlatformCard
and the screen root) and imported into PlatformCard.kt.
- ChannelsUiState/OnboardingPhase imported from the ui.channels package.
- formatTimestamp left in the screen (used by ConfigureForm).
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
* refactor(ui): extract SkillsScreen sub-composables to components package
Split the 914-line SkillsScreen.kt into the screen root plus 4 cohesive
component files under ui/skills/components/:
- SourceBadge.kt: shared SourceBadge (internal, used by both tabs)
- InstalledSkillsTab.kt: InstalledSkillsView + SkillCard + SkillPreviewDialog
- HubSkillsTab.kt: HubBrowseView + HubSkillCard
- SkillEditorDialog.kt: SkillEditorDialog
Wiring required:
- CATEGORY_ALL promoted private -> internal in SkillsScreen (shared by the
screen root and InstalledSkillsView) and imported where needed.
- SkillFilter / SkillsUiState imported from the ui.skills package.
- HubBrowseView now reads isHubSearching/hubResults/hubSearchError off its
SkillsUiState param (fields, not locals) - no behavior change.
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
* refactor(ui): extract SettingsScreen + SystemScreen sub-composables
SettingsScreen.kt (1238 -> ~340): move self-contained sections into
ui/settings/components/ - ConnectionSection (+TestResultCard/SaveIndicator/
TestConnectionButton), BehaviorSection, AppearanceSection (+ChatSection/
NavBarSection), AboutSection, ProfileEditorDialog (+DeleteProfileConfirmDialog).
Shared leaves SectionCard/InfoRow kept in the screen as internal and imported
by the extracted files.
SystemScreen.kt (1755 -> 1619): move CredentialEntryRow + HookCard into
ui/system/components/ (both internal). formatBytes/formatDuration helpers stay
in the screen (used by the root and remaining content).
Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
* fix(#485): use LocalHermesStatusColors for PlatformCard state colors
Map platform connection-status badges to semantic LocalHermesStatusColors
slots (success/warning/error) + add neutral slot for not_configured/disabled.
Removes hardcoded hex (Color(0xFF...)) and MaterialTheme.colorScheme usage
flagged by Sourcery; neutral defaults to StatusGrey so existing theme
providers are untouched.