Commit Graph
115 Commits
Author SHA1 Message Date
M57 d4d6a04afc feat(ui): add CreateBotDialog to BotsScreen with avatar customizer (#981) 2026-08-27 01:51:43 +03:00
M57 4e666b49a8 feat(chat): intercept /new in canonical Bot Chat sessions to /compact (#979) 2026-08-27 01:50:43 +03:00
M57 48c9039e02 feat(ui): add BotsScreen and BotsViewModel with active presence strip (#977) 2026-08-27 01:49:31 +03:00
M57andM57 1f1ebf3597 feat(update): redesign in-app updater with smart cooldown, release notes, and in-place dialog (#971)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-26 07:42:41 +03:00
M57 4549410a45 chore(ui): replace emoji with Material icons across chat UI (#944)
* chore(ui): replace emoji with Material icons across chat UI

Removes emoji glyphs and swaps them for real Material icons / plain text:

- ToolSchemaRegistry: iconEmoji String -> ImageVector icon for all ~35 tools;
  summaryPrefix emoji stripped (terminal keeps '$ '); consumer renders Icon().
- ToolBubble: collapsed summary now shows Icon(config.icon) instead of an emoji
  Text prefix; web-search url drops the link emoji; warning badge uses
  Icons.Filled.Warning instead of a '⚠' Text glyph.
- ChatBubble SelfImprovementReviewCard: 🛠/🧠 String -> Build/Psychology Icon.
- ChatViewModel / ChatWsEventReducer: drop ⚠️/✅ prefixes from user-facing
  status/error messages (severity already conveyed by color/styling).
- SubagentInspectionSheet: ✅/🔄/❌/⭕ status markers -> CheckCircle/Autorenew/
  Cancel/RadioButtonUnchecked (tinted); ElectricBolt section headers ->
  FormatListBulleted + Groups.
- strings.xml (en/zh/ko): drop '✓' from the Copied label.

ktlint clean; compileDebugKotlin SUCCESSFUL. Remaining chat files
(ToolViewBuilder, ChatComposer, MessageCards, ChatReactions, ComposerToolbar,
FullBleedAgentMessage) tracked in follow-up commits on this branch.

* test: align unit tests with emoji-free summary + status messages

composeToolSummaryLines dropped the emoji parameter and no longer prepends
an emoji glyph to the collapsed summary line, so update ToolSummaryLinesTest
call sites + expected strings accordingly.

SlashCommandDispatchRpcTest asserted the old '⚠️ /cmd: ...' error messages;
those prefixes were removed in the UI cleanup, so drop them from the
expected strings.

ChatWsEventReducerTest emoji usages are model-output fixtures, not assertions
on changed code paths — left untouched.
2026-08-21 00:07:37 +03:00
M57 fad49b7b66 feat(chat): compact sticky task progress chip (#942) (#943)
* feat(chat): compact sticky task progress chip (#942)

Re-add a glanceable progress strip above the chat timeline while work is
active. Distinct from the inline TodoTaskCard (permanent timeline record):
this chip is the temporary "what is happening right now" surface.

- Shows only while todos are incomplete or a subagent is running.
- Bounds to the same hydrated state.todos / subagentIndicators the card
  uses -- NOT the old broken state.todos gate removed in #811.
- Label: "Tasks 2/5 · <in-progress task> · N agent" (agent count when relevant).
- Tap opens SubagentInspectionSheet + resumes bottom-follow.
- Auto-hides when all work completes/cancels; rebuilds on session resume.
- i18n strings added (en/zh/ko); ktlint clean; compileDebugKotlin + unit
  tests green.

* refactor(chat): drop inline TodoTaskCard, keep single sticky progress chip

Per follow-up to #942: the inline todo card in the chat view is removed. The
sticky TaskProgressChip above the timeline is now the only glanceable surface;
the tool message itself remains the permanent record.

- Remove inline TodoTaskCard early-return in ToolBubble.kt (todo tool output
  now renders like any other tool bubble).
- Delete TodoTaskCard.kt + TodoTaskCardTest.kt (SecurityRiskChip lives in
  ToolBubble.kt, so risk display is unaffected).
- Redesign chip: replace the ElectricBolt icon with a real
  CircularProgressIndicator (no emoji), keep ChevronRight affordance.
- Label handles the no-todos case (subagents only): 'N agent running'.
- Drop dead todo_task_count string; add task_progress_agent_running (en/zh/ko).

ktlint clean; compileDebugKotlin + chat unit tests green.

* chore: clear pre-existing compile warnings

- HermesWsClient.kt: drop unnecessary safe call on non-null ResponseBody
  (resp.body.string()) and the now-dead 'body != null' guard it implied.
- SessionsScreen.kt: replace deprecated Icons.Filled.CallSplit with the
  AutoMirrored variant (match existing Send usage in this file).

ktlint clean; compileDebugKotlin SUCCESSFUL with no warnings.

* chore(chat): replace emoji status markers with Material icons in inspection sheet

The sticky progress chip opens SubagentInspectionSheet on tap; that sheet used
emoji Text glyphs for status. Replaced with real Material icons:

- Todo rows: completed -> CheckCircle (success), in-progress -> Autorenew
  (primary), cancelled -> Cancel (warning), pending -> RadioButtonUnchecked
  (muted).
- Subagent rows: complete -> CheckCircle (success), failed -> Cancel (error),
  running -> Autorenew (primary).
- Section headers: ElectricBolt -> FormatListBulleted (agent plan) + Groups
  (subagents).

Icons tinted via LocalHermesStatusColors. No emoji remain in the chip's tap
surface. ktlint clean; compileDebugKotlin SUCCESSFUL.

* fix(lint): drop orphaned todo_task_count translation in ko

Removing the string from the default (en) locale left a stale Korean
translation, which tripped Lint's ExtraTranslation error and aborted
lintDebug. Removing the orphan so default + translations stay in sync.

android-lint now BUILD SUCCESSFUL (0 errors).

* fix(chat): address #943 review - progress count, resume hydration, chip tests, plurals

- TaskProgressChip: current-task number is now the first unfinished,
  non-cancelled todo's 1-based position (active index + 1), so a completed
  task 1 + in-progress task 2 reads as 2/5 instead of 1/5 (review point 2).
- Deterministic resume hydration: hydrate todos directly from cached Room
  messages and from the merged REST transcript inside ChatViewModel load
  paths, in addition to the WS reducer, so a resumed session rebuilds the
  chip without waiting for a follow-up event (review point 3).
- Extracted computeChipDisplay / shouldShowProgressChip pure functions and
  added TaskProgressChipTest covering active-task progress, completed/cancelled
  hide, subagent-only state, and transcript-resume hydration (review point 4).
- agent count strings are now Android plurals (task_progress_agents /
  task_progress_agent_running) in en/zh/ko to avoid '2 agent' (review point 5).

Co-Authored-By trailer intentionally omitted per repo convention.

* fix(chat): derive chip number and content from the same selected task

computeChipDisplay selected the task number from the first unfinished/
non-cancelled todo but the content independently from the first in_progress
todo. With a pending task 1 + in_progress task 2 this produced 'Tasks 1/N ·
<task2 content>'. Now both values come from one selected item (prefer
in_progress, fall back to first pending/non-terminal).

Adds two focused tests: same-item selection, and pending-only fallback.
2026-08-21 00:03:50 +03:00
M57andHy4ri 59bcbdfe3c build: Android 17 (API 37) + okhttp 5.5.0 + LAN local-network permission gate (#941)
* build: compileSdk 36 -> 37 + okhttp 5.4.0 -> 5.5.0

Take okhttp 5.5.0 (folds in dependabot PR #940) which hard-requires
compileSdk 37 via AAR metadata. targetSdk stays 36 for now.

* build: targetSdk 36 -> 37 (Android 17) + ACCESS_LOCAL_NETWORK gating

Opt into Android 17 runtime behavior. The app connects to a gateway
(default 127.0.0.1:9119, plus LAN + remote), so targeting 37 forces the
new ACCESS_LOCAL_NETWORK permission for LAN hosts.

- AndroidManifest: declare ACCESS_LOCAL_NETWORK (harmless below API 37)
- LocalNetwork.kt: pure detector + needsLocalNetworkPermission(baseUrl)
  (loopback + public remote exempt; RFC1918/link-local/CGNAT gated)
- ConnectViewModel: requestConnect() gates on the permission; screen
  launches the prompt; onLanPermissionResult proceeds or shows a clear error
- flake.nix: android platform 36 -> 37
- Tests: LocalNetworkTest + ConnectPermissionGateTest (SDK_INT / permission
  branching mocked, no device needed)
- Task 8 finding: assistant services (VoiceInteraction*/Recognition) and the
  remoteMessaging ChatNotificationService do NOT hit the Android 17 mic/
  while-in-use foreground hardening, so no change required.

* fix: ktlint + @Composable LocalContext hoist (was blocking CI)

- ConnectScreen: hoist LocalContext.current out of onClick lambda
  (calling a @Composable inside a non-composable lambda broke compileDebugKotlin)
- ktlint --format: multiline-expression-wrapping, function-signature,
  no-empty-first-line-in-class-body, no-unused-imports
- LocalNetwork.kt: merge consecutive KDocs (no-consecutive-comments)

flake.nix buildToolsVersion/platformToolsVersion intentionally stay 36.0.0
— they are decoupled from compileSdk; only platformVersions needs 37.

* fix: suspend mock (coEvery) + flake toolchain pinned to 37.0.0

- ConnectPermissionGateTest: getStatus() is suspend → use coEvery (was
  breaking compileDebugUnitTestKotlin)
- flake.nix: buildToolsVersion/platformToolsVersion/cmdLineToolsVersion
  pinned to 37.0.0 (verified against pinned nixpkgs 241313f — resolves to
  android-sdk-*-37.0.0 derivations; platformVersions already 37)

* fix(flake): revert cmdLineToolsVersion to 11.0 (not API-aligned)

cmdline-tools uses its own versioning (latest = 21.0, never 37.0.0) —
nixpkgs rejects 37.0.0 with 'version missing'. build-tools + platform-tools
stay 37.0.0 (verified resolve to android-sdk-*-37.0.0). flake now evals
clean against pinned nixpkgs 241313f.

* flake: track nixos-unstable + pin toolchain to latest (cmdline 22.0, platform-tools 37.0.1, build-tools 37.0.0, platform 37.1)

- inputs.nixpkgs -> nixos-unstable, flake.lock bumped to rev ffb3c9b7 (2026-08-19)
- cmdLineToolsVersion 11.0 -> 22.0 (own versioning, not API-aligned; v22_0 confirms)
- platformToolsVersion 37.0.0 -> 37.0.1
- buildToolsVersion stays 37.0.0
- platformVersions 37 -> 37.1
- verified: nix flake check --no-build -> all checks passed on hyari

* fix: unit tests — Uri stub + Build.SDK_INT final-field mock

- LocalNetwork.kt: needsLocalNetworkPermission uses java.net.URI instead of
  android.net.Uri (latter is a non-functional stub in JVM unit tests, its host
  returns null -> gate always false). Added pure requiresLocalNetworkPermission(sdkInt, baseUrl).
- ConnectViewModel: inject sdkVersion (package-private) instead of reading the
  final static Build.VERSION.SDK_INT directly; gate via requiresLocalNetworkPermission.
- ConnectPermissionGateTest: drop mockkStatic(Build.VERSION) (MockKException on
  final static field, and against repo convention); inject sdkVersion; add pure
  gate-decision matrix. Fixes 7 red tests.

* fix(test): remove illegal ':' from test function names (compile error)

* fix(test): order getString mocks so specific resources win over wildcard

* fix(flake): include android-37.0 system image so the emulator can boot

Google only publishes the android-37.0 system image (no 37.1 image exists
yet). With platformVersions=["37.1"] composeAndroidPackages found no matching
system image, leaving system-images/ empty and every AVD FATALing with
'Broken AVD system path'. Adding 37.0 pulls the published image so an AVD boots
(verified: hermes37 AVD boots Android 17 / API 37 on hyari). compileSdk/targetSdk
stay 37.

Also bump AGENTS.md SDK note 36 -> 37 (was stale).

* fix(flake): platformVersions 37.1 -> 37.0

Drop 37.1; 37.0 provides both the SDK platform for compileSdk 37 and the
published android-37.0 system image the emulator needs.

* fix(flake): remove stray tool-scratch line leaked into flake.nix (line 116)

A terminal tool's /tmp scratch rename line got appended to flake.nix, making
it invalid Nix. Removed; flake now parses + 'nix flake check' passes on hyari.

* fix(build): pin buildToolsVersion 37.0.0 to match nix SDK

AGP defaulted build-tools to 36.0.0, which isn't in the nix-managed SDK
(only 37.0.0 is provisioned). Local ./gradlew builds then tried to *install*
36.0.0 into the immutable /nix/store and failed. Pin 37.0.0 so the local
nix devShell builds. CI uses its own SDK so unaffected.

---------

Co-authored-by: Hy4ri <Hy4ri@users.noreply.github.com>
2026-08-20 14:35:42 +03:00
M57andM57 caed4db316 fix(chat): route ALL synthetic backend user rows to system events (#937)
The backend persists a whole class of internal scaffolding as plain
role=user rows with NO display_kind (SessionDB strips underscore metadata):
runtime nudges (max tool-call iterations), context injections ([System:/[CONTEXT/
[PRIOR CONTEXT), background/cron/delegation notices ([IMPORTANT: Background/
Cronjob Response:/[ASYNC DELEGATION), out-of-band markers ([OUT-OF-BAND), and
todo/planning injections ([Your active task list/[Planning state preserved).
These are NOT real human turns, but the mobile rendered them as user bubbles.

Mirror the backend's own synthetic-detection contract
(agent.context_compressor._synthetic_user_row) with a prefix map. Any role=user
row with no display_kind that starts with a known synthetic prefix is routed to
a SystemEvent instead of a user turn. The 90-calls nudge maps to a specific
"Reached tool-call limit" chip; the rest map to a generic "System notice" chip.
Real user messages (no synthetic prefix) are unaffected.

- FullBleedTurns: SYNTHETIC_USER_PREFIX_KINDS map + syntheticSystemKind() that
  tags matched rows so the timeline chip can name them.
- timelineMarkerLabelRes: max_iterations_reached + system_notice labels.
- Strings en/zh/ko for both.
- Tests: assert the new labels map; assert every synthetic prefix routes to a
  SystemEvent (not a user turn); assert a real user message stays a user turn.

The display_kind-tagged events (model/personality switches, auto-continue,
delegation complete, skill invocation, internal notification) are already
handled separately (PR #936, merged).

ktlint + Android Lint + unit tests green locally.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-18 19:04:44 +03:00
M57andM57 0d1b4fee11 fix(chat): give every backend-tagged system event its own timeline chip (#936)
System events ride as role=user rows with a display_kind sidecar (backend
issues #904/#82888). The full-bleed renderer routes any displayKind != null
to a SystemEvent, but timelineMarkerLabelRes only had labels for
model_switch / personality_switch / auto_continue / async_delegation_complete.
skill_invocation and internal_notification had NO label, so TimelineMarkerChip
returned early and they fell through to the bare SystemBubble (centered italic
text, no clear system identity) instead of a distinct chip.

- Add skill_invocation + internal_notification to timelineMarkerLabelRes.
- Add string resources en/zh/ko (ko was missing all timeline markers; added
  the two new ones for parity).
- TimelineMarkerTest: assert both new kinds map to labels.

Result: every backend system message type now renders as its own centered
timeline chip, never as a user bubble. Legacy sessions persisted before the
backend added display_kind tagging still lack the sidecar and would need a
one-time backend re-tag to stop rendering as user bubbles.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-18 17:32:26 +03:00
M57andM57 82e2d787af fix(sessions): show fork icon + order number on nested forked sessions (#934)
* fix(sessions): fork icon + depth number, no indentation

Multiply-nested forked sessions rendered with deep indentation that crushed
the card off-screen and made titles unreadable, and the per-sibling order
number was uninformative (mostly "1").

- SessionTreeItem: replace forkIndex (sibling order) with forkDepth (nesting
  level: 1 = child of a root, 2 = fork of a fork, ...); flattenSessionTree
  populates it.
- SessionCard: render CallSplit fork icon + the depth number (primary tint).
  Removed the depth-based start padding so names stay full-width readable —
  the icon+number now carries the structure instead of indentation.
- strings: sessions_fork_indicator (en/zh/ko).
- SessionTreeTest: assert isFork/forkDepth across nested fixtures.

No backend change — backend already returns parent_session_id per SessionInfo.

* test(ws): make testAutoReconnect tolerate transient RECONNECTING state

The test gated on connectionStatus.first { it == RECONNECTING } with a
15s timeout. With the test backoff forced to 0ms the RECONNECTING state is
emitted + consumed before the collector attaches, so the wait missed it and
timed out (kotlinx.coroutines.TimeoutCancellationException) — the flake CI
saw under parallel load.

- Replace the brittle RECONNECTING .first{} wait with a tolerant check that
  accepts RECONNECTING-or-later (the reconnect clearly fired).
- Keep connect2Latch (second socket opening) as the definitive proof + add a
  final CONNECTED assertion.
- Reset the test backoff in a finally so later tests never inherit 0ms.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-18 17:06:51 +03:00
M57andM57 79c2ea1341 feat(cron): surface last_fire_error on Cron Job records (#925) (#933)
Backend stamps last_fire_error ({at, detail}) on a job when a scheduled fire
can't reach the gateway loopback (gateway down / api_server not bound) —
the "job runs manually but never auto-fires" case. Previously invisible in
mobile.

- CronJob.kt: model as CronJobFireError(at, detail) — the backend sends a
  nested object, NOT a flat string (issue's String? proposal would fail to
  deserialize), plus the last_fire_error field.
- CronJobsScreen: "Fire failed" ERROR badge in the list + "Dispatch Error"
  row (detail + timestamp) in the Run Details dialog.
- Strings en/zh/ko. Test: job with last_fire_error loads + parses.

Backward-compatible: field has a default, existing jobs without it parse fine.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-18 12:53:36 +03:00
M57andM57 1039d7db1d feat(cron): add Run Continuity toggle + badge (#924) (#932)
Expose backend cron self-continuity (context_from=["self"]) in the Cron
editor. Adds a Run Continuity checkbox to the job editor that sends
context_from=["self"] on create and update (null when off, clearing it),
prefills from the loaded job's context_from, and shows a "Continuity" badge
on recurring jobs in the list. context_from was already on CronJob and
CreateCronJobRequest, so this is editor/list wiring only. Strings added for
en/zh/ko. Includes two ViewModel tests.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-18 12:36:05 +03:00
M57andM57 4be1e965f2 feat: render display_kind timeline markers as chips, not user bubbles (#904) (#909)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-15 14:06:51 +03:00
M57andM57 85087c70c7 feat: surface /api/status memory+disk pressure on connect/status screens (#903) (#908)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-15 13:00:08 +03:00
M57andM57 063c27b141 feat(settings): move language to its own section with scalable dropdown picker (#906)
- New Language row in the main Settings list with current-language summary
- New SettingsLanguage drill-down page with an OutlinedButton + DropdownMenu
  picker (scales to any number of languages; same pattern as theme presets)
- Shared supportedLanguages()/languageLabel() helpers — adding a language
  is now one entry in one place
- Removed the 4-option segmented pill row from the Appearance page
- Added settings_sec_language string (en + zh)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-15 12:18:37 +03:00
gslzwhlandhermes-i18n fdbd98af3e Add Simplified Chinese (zh) localization (#901)
* feat: add Simplified Chinese (zh) localization

- Add complete values-zh/strings.xml (921 strings, all keys covered)
- Translate 107 hardcoded UI strings across 34 Kotlin files
- Add 简体中文 option to in-app language picker
- Terminology aligned with official Hermes dashboard zh translations

* fix: resource-ize hardcoded Chinese strings per review

Address Hy4ri's review on PR #901:
- Move 104 hardcoded Chinese UI strings to string resources
  (English in values/strings.xml, Chinese in values-zh/strings.xml)
  referenced via stringResource()
- SystemViewModel labels -> @StringRes + AndroidViewModel with
  resource-based toast templates
- Make TodoTaskCardTest assertion locale-agnostic via getString()
- Update cookie tests to assert new host-agnostic session cookie
  behavior (WiFi/Tailscale switch-safe)
- Session cookie mirroring to WILDCARD bucket survives host switches

* style: apply ktlintFormat per review

---------

Co-authored-by: hermes-i18n <hermes-i18n@localhost>
2026-08-15 11:33:43 +03:00
M57andM57 daf33dcb4c feat(update): notify about app updates on launch (chat banner) (#891)
Fires one silent update check per installed version at app start
(UpdateNoticeManager, release builds only) and shows a non-blocking
banner on the chat screen when a newer release exists. The About tab
adopts the launch result instead of re-pinging GitHub; dismissals
return on the next launch via the persisted latest tag.

Closes #890

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-13 01:08:42 +03:00
M57andM57 685b870cad feat(history): pin sessions to the top of the history tab (#886)
- Hold-menu Pin/Unpin action on session cards (PATCH /api/sessions/{id}
  with {pinned}, verified against the backend SessionRename model)
- Pinned sessions sort to the top on every load (client-side stable sort
  — the backend back-fills pins into page 1 but does not lift them)
- Pin indicator on pinned cards; search-result cards omit the pin action
- Tests: pin-first sort on load, toggle success (moves to top), failure
  (order kept + toast)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-12 14:32:48 +03:00
M57andM57 345d2e4e89 feat(chat): remove legacy bubble renderer, full-bleed is the only chat style (#881)
Deletes the ChatMessageList/ChatStreaming bubble path and the ChatStyle
setting (issue #866 phase 6). ChatScreen now renders FullBleedChatList
directly; ChatBubble becomes the user-message anchor with the shared
SystemBubble/InlineAttachment/formatTimestamp helpers intact. Stubs of
AuthManager.getChatStyle() removed from four test suites; TodoTaskCardTest
renders ToolBubble directly.

Verified: ktlintCheck green, testDebugUnitTest 962/962 pass, assembleDebug
APK builds.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-12 11:02:33 +03:00
M57andM57 a59c8c6a4f feat(chat): full-bleed agent replies behind a chat style setting (issue #866) (#878)
* feat(chat): ChatStyle preference type + store field + AuthManager (issue #866)

* feat(settings): chat style toggle in Appearance (issue #866)

* feat(chat): plumb ChatStyle into ChatUiState + refreshSettings (issue #866)

* feat(chat): full-bleed turn grouping (issue #866)

* feat(chat): full-bleed agent message renderer (issue #866)

* feat(chat): full-bleed compact tool rows + system events (issue #866)

* feat(chat): full-bleed chat list renderer + streaming (issue #866)

* feat(chat): wire full-bleed renderer behind chat style setting (issue #866)

* test(theme): full-bleed contrast gate across all presets (issue #866)

Gate caught NORD light primary/background < 3:1 — header role label
switched to onSurfaceVariant (meta token, correct for header + passes
in every preset; NORD's pastel Frost accent can't reach 3:1 on light).

* test(chat): full-bleed renderer coverage (issue #866)

* style(chat): ktlint formatting pass on full-bleed renderer (issue #866)

* fix(chat): eagerly capture per-item state in full-bleed list (issue #866)

LazyColumn item{} lambdas run at composition time, so loop locals read
inside them saw final values: turn header never rendered (firstProseSeen
always true at compose) and milestones misindexed. Capture showTurnHeader
+ milestone as immutable vals during the DSL loop. Caught by fresh-context
review (decompiled Compose foundation to confirm item composition timing).

* test(chat): stub AuthManager.getChatStyle in mockkObject setups (issue #866)

refreshSettings()/loadSettings() now read AuthManager.getChatStyle();
the four test files that mockkObject(AuthManager) with explicit stubs
had no stub for the new method, so mockk fell through to the real
object and threw 'AuthManager not initialized'. Full suite: 953/953
passing, same shape as pristine origin/main.

* feat(settings): move chat style toggle from Appearance to Chat section

* feat(chat): hoist reasoning above tool rows in full-bleed turns

Tool rows stream in before the answer message, so the turn rendered
tools above the reasoning card. The turn's reasoning now renders at
the top of the agent turn, with tool rows and prose underneath.

Streaming is folded into the turn model (groupIntoTurnsWithStreaming)
so the hoist works live while the answer is still streaming, and the
detached streaming item is gone (which also fixed the duplicate
turn-header quirk for streaming continuations).

* feat(chat): cap reasoning card height + long-press to copy

Expanded trace caps at 40% of the screen with internal scroll (Show
full lifts the cap); long-press anywhere copies the reasoning.

* feat(chat): blinking typing caret while streaming

* feat(chat): make full-bleed the default chat style

* feat(chat): remove thinking dots, show agent header above reasoning

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-12 00:22:07 +03:00
M57andM57 d4008b7b31 feat(cron): confirm before deleting a cron job (#879)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-11 14:17:56 +03:00
M57andM57 f931565c0d feat(settings): in-app update from About tab (issue #867) (#875)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-10 20:02:42 +03:00
M57andM57 96e43f354f feat(system): update progress popup with live action log (issue #863) (#872)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-10 16:16:34 +03:00
M57andM57 a9cb89a925 feat(cron): monitor-mode jobs (monitor_script/monitor_url) + blocked_config status (issue #861) (#871)
- CronJob model gains optional monitor_script/monitor_url (additive-safe)
- Job editor: Monitor mode section (off/script/url) with XOR source clearing;
  disabled while no-agent is set (backend invariant); new jobs apply monitor
  via create -> follow-up PUT with rollback delete on failure (the REST create
  model drops the fields)
- Job list/details: blocked_config renders as a distinct error badge
  ('Blocked (config)'), no_change as informational; monitor source shown in
  run details

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-10 15:35:05 +03:00
M57andM57 57dc4cdf5c fix(sessions): Messages stat card + slim prune button (#855)
* fix(sessions): show Messages stat + slim prune button

The Active stat card always rendered 0: the mobile model declared
'val active: Int = 0', a key GET /api/sessions/stats never sends
(backend returns total/active_store/archived/messages/by_source), and
with ignoreUnknownKeys = true deserialization silently fell back to 0.

Rework the stats row instead of just key-mapping:
- Messages card replaces Active, reading the 'messages' key
  (db.message_count(), web_routers/sessions.py:523-550)
- Prune becomes a slim 52dp icon-only card (contentDescription keeps it
  accessible) so the Messages card keeps width for large counts

* feat(sessions): compact stat counts (100k / 1.23m) for Total & Messages

formatCompactCount renders counts as max 3 digits + unit letter:
987 -> '987', 100987 -> '100k', 1234567 -> '1.23m', 100000000 -> '100m'.
Applied to both the Total and Messages stat cards so large numbers fit
the row. Covered by SessionsStatsFormatTest (5 test cases).

* fix(sessions): label prune + empty-cleanup buttons, drop top select toggle

- Prune card now sizes to its label (icon + 'Prune' text) instead of a
  52dp icon-only square, staying compact via wrap-content width.
- Empty-session cleanup becomes a labeled FilledTonalButton (icon +
  'Delete empty sessions', count badge kept) instead of a bare icon.
- Removed the top-bar select toggle (SelectAll icon); selection mode
  remains reachable via long-press > Select on any session row.
- Dropped now-orphaned content_desc_enter/exit_selection strings (en/ko).

* fix(sessions): prune card wrap-content width + shorter cleanup label

The prune card dropped its fixed 52dp width but kept fillMaxSize on the
inner Box — as an unweighted Row child that expands to the full row
width, starving the weighted Total/Messages cards (reported broken on
device). The Box now fills height only, so the Card wraps to its
content (icon + 'Prune').

Empty-session cleanup button label shortened to 'Delete empty'.

* fix(sessions): stack prune label under icon, cleanup button reads 'Empty'

- Prune card: trash icon above the 'Prune' label (column layout),
  still wrap-content width so Total/Messages keep their space.
- Empty-session cleanup button label shortened to 'Empty'.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-09 15:35:42 +03:00
M57andM57 ab42e19b7f theme(849): unify presets behind one palette template + bespoke AMOLED status colors (#852)
* refactor(theme): unify all presets behind one palette template

* docs(theme): update THEMES.md and AGENTS.md for the palette template

* fix(theme): restore per-preset error slot contrast, merge StatusColors into HermesStatusColors

- onErrorContainer becomes an explicit per-preset field (was derived from
  onError — same-color pairs in Gruvbox/Catppuccin light made error text
  invisible; 6 screens render this slot)
- Default dark + AMOLED onError -> White (were <3:1 on their error color)
- drop StatusColors + statusColorsOf mapper; PaletteColors.status is typed
  HermesStatusColors directly (identical field lists, pure copy ceremony)
- ThemePalette getters now by lazy; schemeFor/statusFor accessors;
  requireNotNull on the default-theme fallback (DefaultTheme is FULL)
- resolvers internal for testability

* test(theme): contrast, mode-validation, and fallback contract tests

- every shipped mode's onError/error + onErrorContainer/errorContainer
  pair meets >= 3:1 WCAG contrast (kills the error-slot regression class)
- ThemeMode init validation rejects illegal palette combinations
- AMOLED darkOnly contract + light-mode fallback to Default light

* refactor(theme): Catppuccin — named swatches from the official palette spec

Hex values verified identical (zero color drift): repeated colors (Crust,
Surface0/1, Base, Text...) now live once as named swatches matching
catppuccin.com; bespoke light primary container tints stay local.
Mocha/Latte pair unchanged.

* refactor(theme): Gruvbox — authentic bright/faded accent scheme with named swatches

Redesign per gruvbox spec: bright accents on dark bg0-bg4, faded accents
on light bg0-bg4 (correct contrast play for light mode). Named swatches,
single Gray shared across modes. Dark error pair fixed for >= 3:1
(onErrorContainer -> DarkFg1 cream, was bright red on Bg3 = 1.87:1).

* feat(theme): replace default preset with Slate — modern slate blues + teal accents

The brand default (voltage purple/amber) becomes Slate: bright teal/blue
accents on dark bg0-bg4, faded variants on light bg0-bg4. Named swatches,
single neutral gray shared across modes. Slate is also the fallback for
darkOnly/lightOnly presets (AMOLED light mode).

* feat(theme): add Nord preset — Polar Night/Snow Storm surfaces, Frost/Aurora accents

Nord's single accent set is reused across both modes (pastel accents stay
light, so on* colors use Polar Night dark text in both modes — documented
in-file). Surface ladder shares outer tiers (Nord ships 4 dark + 3 light
tones for 5 tiers). Dark error pair fixed for >= 3:1 (onErrorContainer ->
Nord6, was Nord11 on Nord2 = 2.0:1). Wired into ThemePreset, the settings
picker, and docs; default label now reads Default (Slate).

* refactor(theme): Monochrome + AMOLED — pure grayscale ramp, true-black AMOLED variant

Monochrome: every swatch R=G=B, roles by lightness only (status colors
included — documented trade-off: pair with icons/labels). AMOLED: dark-only
true-black variant (pure #000000, OLED pixels off; #121212 baseline of
Monochrome doesn't do that), containers collapse where nothing darker
exists. Both ship maximum-contrast grayscale pairs (12.8:1+ on error
slots).

* chore(theme): drop Neon Noir, finalize docs + picker label

- remove NeonNoirScheme.kt + ThemePreset.NEON_NOIR everywhere (dispatcher,
  picker, strings EN/KO, tests, AGENTS.md, THEMES.md) — 6 presets remain:
  Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord
- picker label for the default preset is now plain 'Default'
- THEMES.md: preset conventions section (named swatches, bright/faded
  split, grayscale-status caveat, contrast test gate) + verify step runs
  testDebugUnitTest

* fix(theme): make drawer icon colors visibly follow the active preset

Selected icon now tints with the theme's primary accent (teal for Slate,
orange for Gruvbox, mauve for Catppuccin, frost-blue for Nord) instead of
onPrimaryContainer — the new presets' containers are all dark gray with
near-white text, so the selected row looked identical across themes.
Unselected icon/text colors pinned explicitly to onSurfaceVariant.

* chore(ui): remove lightning emoji from the app

- drawer title + About name: '⚡ Hermes' -> 'Hermes' (EN + KO)
- skill markers (chat self-improvement badge, memory graph): ⚡ -> 🛠
- in-progress indicators (subagent inspection): ⚡ -> 🔄

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-08 15:58:25 +03:00
M57andM57 f20b50b48f feat(sessions): empty-session cleanup + latest-descendant resume + fresh-session 404 fix (#787, #840) (#841)
* feat(sessions): empty-session cleanup button + latest-descendant resume + fresh-session 404 fix (issues #787, #840)

- Sessions top bar: Delete-empty icon with count badge, grey/disabled when 0
  (GET /api/sessions/empty/count + DELETE /api/sessions/empty, confirm dialog)
- Resume flow resolves GET /api/sessions/{id}/latest-descendant first —
  branched sessions continue in the newest child leaf, not the stale parent
  (best-effort; falls back to the requested id)
- syncCurrentSession guarded by sessionHasServerPresence — a freshly created
  (never prompted) session has no server row yet; the REST transcript 404s
  and the resume retry spun forever (logcat 404 evidence)

* fix(sessions): clamp message-count fallback to backend limit cap (422)

fetchServerMessageCount requested limit=500 but the backend caps
session list page size at 100 (sessions.py Query le=100) — every
fallback scan 422'd (seen in device logcat after a branch). Sessions
are ordered recent, so the target is always in the top page.

* test(slash): make dispatch-capture assertions order-independent (CI flake)

The capture-race flake (SlashCommandDispatchRpcTest, hit 3x across CI and
local runs 2026-08-07): tests asserted on methodSlot.captured — the LAST
request captured — but the create flow's session.usage/context_breakdown
requests can land after the slash-dispatch one depending on suite order
(the class's own comment documents this cross-wiring). Converted the
three capture-based tests to capture ALL requests and find the target
record by method, with dispatch-before-fallback ordering for the 4018
test. Same assertions, no dependence on request interleaving.

* test(slash): convert remaining capture-race assertions (flake round 2)

The capture race moved to the focus/init tests (:424) after the first
three were fixed — every request-capture test in the class used the
same last-captured assumption. Converted the remaining five (init,
init+arg, focus, focus on, type=send) to the find-record-by-method
pattern; the type=send answers block reads methodCalls.last() since the
capture precedes the answer. Zero request() slot captures remain.

* test(slash): blocklist assertion counts command RPCs only (flake round 3)

The blocklisted-command test counted ALL request() calls and asserted
zero — the create flow's session.usage/context_breakdown noise can land
in the window after the stub registers (the same capture race that hit
the dispatch tests). Asserted instead that no COMMAND_DISPATCH or
SLASH_EXEC fires, which is the actual blocklist contract. E2E's static
Dispatchers mock stays (E2E VMs need IO routed to the test dispatcher —
removing it broke 20 E2E tests).

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-07 21:36:23 +03:00
M57andM57 7998248507 feat(system): multipart backup import via SAF picker — replace path-based import (issue #786) (#839)
* feat(system): multipart backup import via SAF picker — replace path-based import (issue #786)

* fix(system): backup trigger sends required JSON body — 422 fix (BackupRequest contract)

* fix(system): backup download waits for async archive + saves to Downloads

* fix(system): curator min_idle_hours Double parse + single-flight backup download

* fix(system): stream backup download to Downloads — @Streaming + chunked MediaStore write (OOM fix)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-07 16:44:41 +03:00
M57andM57 9d0baa78fd fix(sessions): rename via PATCH /api/sessions/{id} + move actions to hold menu (issue #785) (#838)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-07 14:20:21 +03:00
M57andM57 7d00d7b81b feat(memory): dedicated Memory screen (moved out of System tab) (#835)
* feat(memory): dedicated Memory screen — providers, builtin files & reset (moved from System tab)

* feat(memory): move self-improvement & learning activity section from System tab to Memory screen

* fix(memory): remove top padding, single-line reset buttons, providers spacing

* fix(memory): tighten provider card padding

* fix(memory): distinct AutoStories icon for Memory tab

* chore: ignore local debug apk

* feat(ui): fresh icon set for drawer screens

* feat(ui): custom Neurology icon for Memory tab

* fix(ui): theme-driven fill for NeurologyIcon (checkColorLiterals)

The custom Neurology vector used SolidColor(Color.Black), tripping the
hardcoded-color guard (issue #622). The fill only contributes alpha —
Icon()'s tint replaces the RGB at draw time, mirroring Material's own
icon set — so the literal belongs in theme/ like every other hardcoded
color: new IconDefaultFill token, scoped there by the guard's design.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-07 04:18:50 +03:00
M57andM57 fd5d851405 feat(plugins): memory provider config & setup management (issue #783) (#834)
* feat(plugins): memory provider config & setup management (issue #783)

* fix(memory): remove top padding on provider detail screen

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-07 04:18:50 +03:00
M57andM57 fe79ff42b4 feat(toolsets): per-toolset config/env/provider/post-setup management (#782) (#831)
* feat(toolsets): per-toolset config/env/provider/post-setup management (#782)

* fix(toolsets): per-entry toolset name for shared detail ViewModel (#782)

NavDisplay uses the activity-level ViewModelStoreOwner, so class-keyed
viewModel{} returns the same ToolsetDetailViewModel for every detail
entry; the constructor-captured name froze the first-opened toolset into
all entries (on-device: every toolset showed web's providers, no further
/config calls after the first). The screen now drives setToolset(name) +
loadConfig() per entry, and setToolset resets per-toolset state
(config, revealed secrets, running post-setup) so nothing bleeds across
toolsets.

* style(toolsets): single-line scrollable tool chip strip (#782)

A wrapping FlowRow stacked chips vertically for toolsets with many
tools (web etc.), ballooning card height — especially on large font
scales. Chips now sit on one horizontally scrollable row.

* chore(toolsets): drop stray terminal-noise line from chip strip change

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-06 17:57:58 +03:00
M57andM57 442e868ce2 feat(cron): blueprint start-from + delivery target picker in job editor (issue #780) (#822)
- New-job dialog gains a Start-from dropdown: pick a catalog blueprint, fill its typed slots (time/enum/weekdays/text), save instantiates it via POST /api/cron/blueprints/instantiate
- Delivery field is now an editable picker fed by GET /api/cron/delivery-targets (origin + local + connected platforms)
- Blueprints and targets fetched on editor open; 422 slot errors surface the backend detail

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-05 22:44:13 +03:00
M57andM57 80be168801 feat(profiles): delete, rename, auto-describe and setup command (issue #781) (#818)
* feat(profiles): delete, rename, auto-describe and setup command (issue #781)

* feat(profiles): use shared ModelPickerDialog for set model (issue #781)

* fix(profiles): move overflow menu to card header so it can't be clipped (issue #781)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-05 17:59:30 +03:00
M57andM57 1870b375b1 feat(skills): hub sources + featured landing and security scan (#779) (#793)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-03 22:13:28 +03:00
M57andM57 00a0eedc77 feat(logs): server-side file/level/component/line filters (#777) (#792)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-03 20:28:30 +03:00
M57andM57 5870b73397 feat(#776): pairing management screen (#791)
REST + Channels-style UI for DM pairing (list pending/approved, approve, revoke, clear).

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-03 19:49:31 +03:00
M57andM57 44767fef4a feat(kanban): live board updates over plugin events WebSocket + kanban UI pass (#789)
* feat(kanban): live board updates over plugin events WebSocket (#775)

Tail /api/plugins/kanban/events per board with cursor tracking and
backoff reconnect; debounced REST reload on event batches (desktop's
proven pattern — event payloads are partial updates). Board switch
re-subscribes with a fresh stream. Reuses HermesWsClient's ticket
refresh (now internal). Adds ServerEndpoint.webSocketUrl(path, ...)
overload for non-chat WS endpoints.

Kanban UI pass: add-task FAB (the AddTaskDialog was unreachable),
live/offline status pill, assignee chip on task cards, 2-line
description clamp, empty-column hint.

Tests: KanbanEventsClient frame parsing, KanbanViewModel WS wiring
(connect on load, debounced reload, board-switch reconnect,
no-reconnect guard, onCleared), ServerEndpoint overload.

* fix(kanban): create task response is wrapped {"task": ...} — read it as Unit

Backend POST /api/plugins/kanban/tasks returns {"task": {...}} (wrapped),
but the app declared Response<KanbanTask> — with ignoreUnknownKeys=true
the wrapper key is skipped and the required id/title/status fields are
missing, so deserialization threw and the create showed a failure toast
while the task WAS created server-side (ghost tasks). The body is never
consumed by the ViewModel, so Response<Unit> matches updateKanbanTask.

FAB from the live-events PR (#775) made this latent bug reachable.
Adds an E2e regression assertion for the create path.

* feat(kanban): remove move left/right arrows — backend rejects the transitions

The dashboard PATCH route's status whitelist omits 'review' (a first-class
kanban_db status), so any move touching that column 400s, and reopen
transitions (done->blocked) 409. Every task on the live board sits in
'done', making the arrows 100% failure. Removing them keeps the UI honest;
tasks are managed from the desktop/CLI.

Drops moveTask/revertTaskMove from the VM, the arrows from TaskCard, the
now-dead updateKanbanTask API method and move strings; E2e test keeps
load/create coverage.

* feat(kanban): desktop-style task transitions — gated action sheet

Mirrors the desktop kanban's move model: tap a card to open an action
sheet with only the transitions the backend PATCH route accepts:

- triage / ready from any status (direct write; unblock from blocked)
- block only from running/ready, complete only from running/ready/blocked
- archive from any non-archived status
- never running (dispatcher-only, backend 400s) and never review (not in
  the dashboard whitelist) — the moves that made the old arrows lie

Complete prompts for a mandatory completion summary (stored as result),
matching the desktop; block/complete/archive ask for confirmation.
Optimistic update with revert + toast on failure.

Tests: transition-gating map, move PATCH body, failure revert, completion
summary body; E2e kanban test covers a desktop-style todo->ready move.

* ui(kanban): replace oversized FAB with compact top-bar add button

The 56dp FAB floated over the board's bottom-right corner and covered a
chunk of the last column. The add action now lives in the header row next
to the live pill (input-top style), and the board's bottom contentPadding
drops back to 16dp — no more reserved dead zone.

* fix(kanban): mint an independent WS ticket — don't touch the chat's shared slot

The dashboard WS ticket is single-use and consumed at handshake. Both the
chat client and the kanban events client were refreshing into the SAME
shared AuthManager token slot on their own schedules (the kanban client on
every backoff reconnect, forever). Whichever client built its URL second
grabbed the other's just-consumed ticket, got 1008, reconnected, and stole
the other's next ticket — mutual starvation that left the app stuck at
'Reconnecting...' (repro on main: none, because main has one consumer).

HermesWsClient now exposes mintWsTicket(): a ticket minted WITHOUT touching
the shared slot (cookie-auth'd, same endpoint). The kanban events client
uses its own ticket per connection; the chat WS keeps the shared slot.
Refresh's HTTP-error -> status mapping is preserved via a small result
holder.

* fix(kanban): drop orphaned ko translations for removed move buttons

The move arrows were removed earlier but values-ko still shipped
content_desc_move_left/right — Android lint ExtraTranslation failed CI.

* fix(ws): keep RECONNECTING on transient ticket-mint exceptions

My refactor flattened the ticket-mint failure mapping: a network exception
during POST /api/auth/ws-ticket was reported as a null HTTP code and
mapped to DISCONNECTED, breaking the #740 contract that transient failures
stay in RECONNECTING (and with it testGatedMode_transientTicketFailure-
KeepsReconnectFlow). TicketRequestResult now carries an exception flag and
refreshWsTicketIfNeeded maps it to RECONNECTING.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-03 18:19:08 +03:00
M57andM57 1e8c3629f0 chore: rename launcher label to 'Hermes Mobile' (#770)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-02 12:03:14 +03:00
M57andM57 5e1ee0b4e5 feat(chat): desktop-parity session resume — warm cache + bounded retry (#766)
Bring the desktop's session reconnection UX to mobile:

- Warm-cache fast-path: switchSession paints the cached Room transcript
  immediately instead of blanking the screen, then refreshes from the
  server in parallel (stale cache can't clobber a fresher server page)
- Bounded auto-retry: a failed session.resume (WS reject or REST
  transcript failure) retries with exponential backoff 1s->2s->4s->8s,
  capped at 4 attempts; WS + REST failures in the same cycle count as one
- Explicit error recovery: after exhaustion the chat shows an error card
  with a manual Retry instead of latching the spinner forever; manual
  retry resets the backoff cycle
- Reconnect-aware resume: gateway (re)connect clears stale error state
  and re-resumes the current session on the new socket
- Unit tests: warm-cache paint, bounded retry exhaustion, WS+REST
  dedupe, manual retry reset, reconnect restart

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-02 00:04:23 +03:00
M57andM57 422bb9f5e6 feat: support hosted OAuth for authenticated HTTP MCP servers (#751) (#752)
* feat: support hosted OAuth for authenticated HTTP MCP servers (#751)

* fix(mcp): import McpOAuthFlowResponse in McpServersViewModel

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-31 05:30:51 +03:00
M57andM57 1b596746af feat(chat/activity): Self-improvement event badges for memory & skill updates (#744)
* feat(chat/activity): Self-improvement event badges for memory & skill updates (#737)

* feat(chat): Handle background review.summary WS events for self-improvement notices

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-29 21:43:08 +03:00
M57andM57 f45a68b0db feat(chat): full-screen image viewer (issue #723) (#729)
* feat(chat): full-screen image viewer (issue #723)

Tap any chat image (markdown ![alt](url), 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>
2026-07-27 15:21:19 +03:00
M57andM57 6aee02c4da feat: add Files tab backed by the managed-files endpoint (#727)
* 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>
2026-07-26 22:46:36 +03:00
M57andM57 0180fb12c2 Add used/full context meter to chat screen (#717)
* 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>
2026-07-26 01:52:22 +03:00
M57 9089b4f8ac feat(keys): overhaul Keys screen UI and UX (#707)
* 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
2026-07-24 02:16:22 +03:00
M57andM57 2e97ee9a84 refactor: remove verified dead code across data, UI, and resources (#680)
* refactor: remove unused dead code across data, UI, and resources

* fix: resolve dangling references left by dead-code removal

Restore empty object Motion (braces required) and remove the 12 stale
test usages that still called the now-deleted production symbols:
- ServerStoreTest: removeServer/switchToServer tests
- ServerEndpointTest: isCleartext assertions
- ConnectViewModelTest: onHostChange/onPortChange tests + 3 dropped R.string refs
- SettingsViewModelTest: renameProfile tests + clearNavigateToLogin
- E2eIntegrationTest: previewSkill tests + onHostChange/onPortChange calls

Verified locally: compileDebugUnitTestKotlin + 48 tests pass.

* fix: remove unused imports flagged by ktlint

Drops 4 no-longer-referenced imports left by the dead-code removal:
- HermesApiService: SkillScanResponse (scanHubSkill removed)
- ServerEndpointTest: assertFalse, assertTrue (isCleartext assertions dropped)
- SettingsViewModelTest: assertFalse (rename tests removed)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-22 15:51:07 +03:00
M57andM57 0edb67338d refactor: remove bottom navigation bar (#665 #666 #667 #668) (#672)
* refactor(nav): remove bottom NavigationBar from Scaffold (#665)

* refactor(theme): remove BottomNavDisplayMode enum (#666)

* refactor(settings): remove nav bar customization page (#667)

* chore: clean up unused imports after bottom nav removal (#668)

* chore: remove dead bottom nav infrastructure after removal

- Remove SettingsNavBar NavKey (NavigationKeys.kt)
- Remove bottomNavItems field from ServerStoreState + migration code
- Remove _bottomNavItemsFlow, getBottomNavItems, setBottomNavItems from AuthManager
- Remove bottomNavItems handling from selfHealed() (ServerCollectionOps)
- Remove redundant bottomBar={} from Scaffold (Navigation.kt)
- Remove unused nav bar strings from values/strings.xml + values-ko/strings.xml
- Remove content_desc_move_up/down/remove_item strings
- Update SettingsViewModelTest.kt (drop stale getBottomNavItems mock)
- Update ServerStoreTest.kt (remove bottomNavItems assertions)

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-22 10:14:50 +03:00
M57andM57 a62bc7e16c refactor: remove device Pairing feature entirely (issue #645) (#650)
* refactor: remove device Pairing feature entirely (issue #645)

- Delete Pairing.kt model + pairing API endpoints (get/approve/revoke/clear)
- Delete PairingScreen/PairingViewModel + PairingCodeEntryScreen/ViewModel
- Remove NavKeys, ScreenRegistry drawer entry, Navigation.kt entry + Landing pairing button
- Remove pairing string resources (en + ko); keep connect_* config-parse strings
- Keep Telegram onboarding pairing_id flow (separate concept, per issue)
- Fix related doc comments + remaining test reference

* test: fix racy CONNECTED read in gated-mode WS test (issue #635 test)

testGatedMode_attemptsTicketFetchWithoutBareCookie asserted CONNECTED
immediately after the server-side onOpen latch fired, racing the client
handshake (WsListenerImpl sets CONNECTED on receipt of the 101). Await the
real status transition via connectionStatus.first{==CONNECTED} like the
other connect tests do. No product behavior changed.

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-20 23:23:29 +03:00
Hy4ri 367d6e22f5 fix: language selector 2026-07-20 17:58:28 +03:00