Commit Graph
268 Commits
Author SHA1 Message Date
AlexDev_ 5a15ccc524 fix(chat): route back navigation to history (#809) 2026-08-04 18:09:26 +03:00
AlexDev_ d1dac57ccb fix(ws): reconnect after network handover (#808) 2026-08-04 18:09:22 +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 b91ac044b9 fix(chat): never resume sessions the gateway has no row for (4007 dead-end) (#790)
First open creates a session whose DB row the gateway persists lazily on the
first prompt, so a freshly created (never prompted) session CANNOT be resumed.
Any WS reconnect while sitting on it — tab switch + socket blip, app
background, gateway restart — re-fired session.resume on that key; the gateway
answered 4007 "session not found" and the bounded retry (desktop parity, #766)
burned its budget on a permanently invalid key, leaving an error popup whose
Retry could never succeed.

- Skip the reconnect re-resume (and the doomed REST transcript fetch) while the
  current session has no confirmed server-side row (REST 200 / resume success /
  first MessageStart = first prompt accepted). Sessions picked from History
  stay optimistic-resumable even while their page transiently 5xxes.
- Treat a definitive "session not found" (4007 RPC / 404 REST) as terminal:
  recover with a fresh chat + notice instead of the dead-end Retry overlay.
- session.branch: keep the storage key in currentSessionId (the handler stored
  the runtime registry id, so every later resume 4007'd and the transcript 404).

Tests: reconnect on an unconfirmed session must not resume or error; 4007 must
recover with a new session (deduped, notice shown); branch keeps the storage id
(currentSessionId + transcript fetch key). RED-GREEN proven: all three fail on
the pre-fix code and pass with it.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-03 19:11:43 +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 b4d0a7c9c3 fix(chat): preserve reasoning across tool calls + REST reload (#771) (#771)
* fix(chat): preserve reasoning across tool calls + REST reload (#771)

A reasoning-model turn streams its thinking BEFORE the tool starts. The
reducer wiped the streaming state at tool.start (returned a fresh
StreamingState()), so the reasoning bubble vanished mid-turn and the
finalized answer had no reasoning card.

Real gateway capture for `run echo hi` (reasoning model):
  message.start
  reasoning.delta xN          (thinking before the tool)
  tool.generating/start/complete (terminal)
  message.delta xN            (answer after the tool)
  reasoning.available         (FULL trace in payload text)
  message.complete            (text + reasoning field)

Fixes:
- onToolStart keeps the streaming message + reasoningText alive across
  the tool call (only buffers are cleared via clearStreamingBuffers);
  an interim-text orphan still seals, but reasoning survives for the
  post-tool answer
- EventParser now reads reasoning.available text and message.complete
  reasoning (the authoritative full trace the gateway sends)
- onReasoningAvailable fills the streaming message with the full trace
- onMessageComplete prefers the payload reasoning over delta leftovers
- mapServerMessages folds reasoning-only empty assistant rows into the
  next real answer bubble (no more standalone reasoning ghost bubble)
  and carries tool names over from the live WS list by position (no
  more generic 'tool' title after a REST reload)

Regression tests replay the captured sequence end-to-end.

* fix(chat): dedupe WS + REST tool bubbles by result content (#771)

The REST transcript tool rows carry no tool name, and WS messages (UUID
ids) + REST rows (rest- ids) are BOTH persisted to Room — the cache
paint and reloads could render the same tool call twice, with the
second bubble falling back to the generic 'tool' title.

Real captured pair for 'echo meow':
  terminal  {tool_id, name, args, result:{...}}          (WS)
  Tool      {output, exit_code, error}                   (REST, no name)

The previous positional carry-over misaligned whenever the REST page
and the live tool list didn't line up 1:1 (multiple tool calls in
history → newest call got null name).

Fixes:
- canonicalToolResultKey(): content-based matching between the WS
  tool.complete payload and the REST result row — int/float agnostic
  (0 vs 0.0), prefers the payload's 'result' field
- mapServerMessages(): when a REST tool row's result matches a live WS
  tool message, REUSE the live message wholesale (same id + toolName +
  rich payload) so Room upserts instead of accumulating a rest- copy
- dedupeCachedMessages(): cache paint drops rest- copies whenever a WS
  copy of the same logical message exists (tools matched by result,
  other roles by content)

11 new unit tests with the real captured payloads.

* fix(chat): merge transcript reload with live WS bubbles (#771)

The gateway persists a tool row only when the tool COMPLETES server-side.
A REST transcript reload landing mid-turn (app background/foreground,
reconnect re-resume, pull-refresh) therefore returned a page WITHOUT the
in-flight tool row — replacing the message list outright made the tool
bubble vanish and left tool.complete with no RUNNING message to update.

- mergeTranscriptWithLive(): reload now merges instead of replacing —
  any current message the REST page doesn't cover (by id or logical
  content) survives, chronological order preserved
- loadSessionMessages applies the merge before publishing state

4 new unit tests: in-flight RUNNING tool survives a mid-turn reload,
reused-id and canonical-match cases don't duplicate, order preserved.

* test(chat): end-to-end tool-bubble survival through mid-turn reload

Chains the real user scenario at the state level: WS turn (message.start →
reasoning → tool.start → tool.complete) + a transcript reload landing
while the tool is still running server-side (REST page lacks the tool
row) + stream finish. Asserts the tool bubble survives to the final
list with its name and status intact.

* debug(chat): add Logcat tracing for tool-bubble vanish (#771)

Temporary diagnostic logging to catch the exact moment and code path
where the tool bubble disappears. Logs to tag 'ChatReducer771' and
'ChatVM771' at every state mutation that touches messages:

- onMessageComplete: message count + roles before the assistant append
- loadSessionMessages: rest vs current vs merged counts and roles
- syncCurrentSession: trigger + current tool count
- sync merge: before/after merge counts and roles

To reproduce: install this debug APK, run 'echo meow', watch the tool
bubble vanish, then capture logcat:
  adb logcat -d -s ChatReducer771:D ChatVM771:D

Will remove the logging once the root cause is pinned.

* fix(chat): drop debug logging + harden sync merge (#771)

The debug commit added android.util.Log calls in the reducer's
onMessageComplete path, which is a Stub! in JVM unit tests — every
test touching that path died with RuntimeException, and the long
log lines broke ktlint (max-line-length). Remove them.

Sync merge fix: the 5s sync poll could drop the newest WS tool
bubble when the REST page lagged one row behind. Route the merge
through sameLogicalMessage (canonical result-key match) and keep
any WS message that has no REST counterpart.

Adds 2 regression tests for the sync-merge scenario.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-02 23:03:23 +03:00
M57andM57 ec72ed4733 feat(chat): subtle tool-call divider every 5 calls per turn (#769)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-02 15:22:29 +03:00
M57andM57 c50a178845 fix(chat): skip live-registry context RPCs until resume confirms runtime session id (#768)
Opening a session from history fired session.context_breakdown/session.usage
with the storage session id before session.resume had registered it on the
gateway. Those RPCs resolve against the LIVE runtime registry (_sess_nowait)
and returned 4001 "session not found", which the reducer surfaced as a
spurious snackbar even though the session loaded fine.

- fetchContextUsage() now skips the two live-registry RPCs while
  runtimeSessionId is null (pre-resume window); the REST-based context
  fallbacks stay live.
- runtimeSessionId is cleared on socket drop so a stale runtime id can't be
  reused after a gateway restart/reconnect (same 4001 family).
- regression test: no context/usage RPC fires with the storage id during the
  pre-resume window; after the resume result the RPCs fire with the runtime id.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-02 00:45:16 +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
M57andhy4ri 982f8aec4e feat(chat): rewire tool bubbles onto the desktop engine (stage 3) (#765)
* feat(chat): rewire tool bubbles onto the desktop engine (stage 3)

parseToolOutput is now a thin adapter: it normalizes the payload shape
(args/result sub-objects, legacy top-level fallback, name resolution)
and delegates all per-tool formatting to ToolViewBuilder.

- ToolResultParser.kt: 1325 lines of bespoke formatters → 45-line adapter
- ParsedToolData deleted; ToolBubble renders ToolView directly:
  collapsed = emoji + engine title (+countLabel) + subtitle line;
  expanded = error → $ command / stdout / stderr / exit code →
  DiffViewCard → search hits → detail → duration footer
- Gson-compat shims, formatTodoToolOutput, toIntOrNull and all
  per-tool formatters died with the old parser (engine is idiomatic kotlinx)
- Kept mobile-only UI: progress preview, SecurityRiskChip, emoji
  prefixes, raw-JSON toggle, DiffViewCard, copy
- ToolViewBuilder fixes found during the rewire: numberValue now accepts
  numeric strings (TS Number() semantics — gateway ships exit_code as
  string/float), memory title keeps its target suffix

ToolBubbleParsingTest rewritten as adapter-contract tests (14 cases);
per-tool behavior lives in ToolViewBuilderTest. Verified: ktlint +
full testDebugUnitTest.

* fix(chat): dedupe tool name in collapsed bubble summary

The header row already shows the tool name, but the collapsed summary
showed emoji + engine title — and for ~11 tools the engine title is just
the generic humanized name (fact_store -> "Fact Store", todo -> "Todo"),
so the bubble displayed the tool identity twice.

composeToolSummaryLines now drops the title when it equals the generic
humanized name and merges the subtitle into the emoji line instead
("🧠 Fact added (ID: 5)"), restoring the pre-engine one-line style.
Descriptive titles ("Searched \"cats\"", "Ran ls", "Read a.kt L10-14")
stay on line one with the subtitle on line two. A lone emoji with no
subtitle renders nothing.

ToolViewBuilder.genericTitleFor exposes the generic title for the
comparison. 9 new unit tests.

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-08-01 23:23:11 +03:00
M57andhy4ri 100a82dd4f feat(chat): port desktop tool display engine (ToolResultSummary + ToolViewBuilder) (#763)
* feat(chat): port desktop heuristic JSON summarizer engine

New com.m57.hermescontrol.ui.chat.tool.ToolResultSummary — a faithful
Kotlin port of the desktop app's lib/tool-result-summary.ts (verified
against its vitest suite, ported 1:1 to JUnit):

- formatToolResultSummary: wrapper unwrap (data/result/output/response/
  payload, up to 4 levels), priority-key ordering, array/record block
  formatting with depth caps, noise filtering (success:true etc.)
- extractToolErrorMessage: cycle-safe nested hunt for real error text
  through wrappers, with placeholder filtering (error:'none' ignored)
  and stderr deliberately excluded as an error signal

Also handles stringified-JSON payloads (re-parse on demand) like the
desktop's tryJson/norm layer.

Ported contract tests: ToolResultSummaryTest (10 cases).

* feat(chat): port desktop per-tool display engine (ToolViewBuilder)

Second half of the desktop engine port, in the new ui.chat.tool package:

- ToolView: the render model the builder produces — status/title/subtitle/
  detail/count/duration/stdout/stderr/exitCode/diff/searchHits/imageUrl —
  no raw JSON in UI code
- ToolViewBuilder: port of the desktop buildToolView (fallback-model):
  status heuristics (explicit success wins; non-zero exit alone is NOT an
  error), dynamic titles (Read/Searched/Ran with summarized commands),
  per-tool subtitle/detail (browser snapshot stats, terminal output
  preview, memory message, cron job counts), count-label cascade,
  web_search hit parsing, inline diff + stats, stdout/stderr split,
  duration formatting, redundant-detail suppression
- Mobile-specific tool cases carried into the builder: todo, fact_store,
  session_search, skills_list/view, skill_manage, process, x_search,
  vision_analyze, tool_search, image_generate, project_*, read_terminal,
  computer_use
- CommandSummarizer: port of the desktop summarize-command.ts (quote-aware
  compound split, pipe-tail strip, redirect/env cleanup)
- Terminal stdout reads the gateway's canonical merged `output` key with
  stdout/stderr fallback (mobile terminal_tool contract differs from
  desktop)

Tests: ToolViewBuilderTest (18), CommandSummarizerTest (8), all ported
or derived from the desktop suite. Full suite green: 655 tests.

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-08-01 22:01:13 +03:00
M57andM57 3a96edd4c1 fix(chat): clear stale reasoning when a new message starts (#755) (#760)
A new assistant message was seeded with the previous message's
reasoningText (and isReasoning flag) via onMessageStart, and
resetStreaming() never cleared the shared streaming state. If the
next reply produced no reasoning, the stale trace stayed glued to
the new bubble and got persisted to Room.

- onMessageStart: new message + StreamingState start with empty
  reasoning instead of inheriting the previous turn's
- resetStreaming: clears isReasoning + reasoningText on the shared
  state (finalized messages keep their own copy for the card)
- regression tests: MessageStart seeding, no-reasoning persistence,
  reset clearing, finalized-message preservation

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-01 20:14:35 +03:00
M57andM57 e014d2a0e7 fix(chat): context meter reflects compression via session.context_breakdown RPC (#759)
* fix(chat): context meter reflects compression via session.context_breakdown RPC

The chat context meter read GET /api/sessions/{id} input_tokens — a
cumulative lifetime counter that compression never resets, so the meter
only ever grew and sat red after the first compaction.

Mirror the desktop: read live occupancy from the session.context_breakdown
WS RPC (context_used = compressor last_prompt_tokens, falling back to an
estimate of the live system prompt + tools + compacted history). The
compressor zeroes last_prompt_tokens when it runs, so the meter now drops
right after auto-compaction or /compress, exactly like desktop/web.

REST getSessionDetail is kept solely to feed the detail sheet's cumulative
token accounting. RPC failures keep the last known values; the meter never
blanks. Fixes #756.

* feat(chat): compressed xN badge on the context widget

The context chip now shows how many times the current session has been
context-compressed, sourced from the session.usage WS RPC (compressions =
context_compressor.compression_count) — the same usage snapshot the desktop
status bar reads. The badge explains a meter drop after auto-compaction or
/compress: the widget no longer just shows the number, it shows the story.

- UsageSnapshotResponse model + parseUsageSnapshot (Number-safe like the
  breakdown parser — WS parser decodes JSON numbers as Double)
- compressionCount added to ChatUiState, reset on session switch/create/branch
- session.usage fires alongside session.context_breakdown in the 5s poll;
  failures keep the last known badge value (best-effort, never blank)
- 3 parser tests; full local suite 615 tests green

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-01 19:46:49 +03:00
M57 63b966b133 feat(chat): add inline video player component and modal viewer (#722) (#732)
* feat(chat): add inline video player component and modal viewer

Closes #722

* feat(chat): modernize video player with compact lock pill, save, and share

* refactor(chat): simplify inline video thumbnail to launch fullscreen player on tap

* fix(chat): pass session cookie and auth token headers to VideoView for protected media playback

* fix(chat): format unquoted cookie header for VideoView auth

* fix(chat): resolve video URLs via resolveImageUrl for gateway-hosted media
2026-08-01 00:47:26 +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 2cf41ead06 feat(chat): live task delegation widget with inline card & sticky progress indicator (#736) (#743)
* feat(chat): live task delegation widget with inline card & sticky progress indicator (#736)

* fix(chat): refine subagent isRunning status and fallback Compose list keys (#743)

* feat(ci): add CodeQL analysis workflow & integrate todos into task widget (#736)

- Add CodeQL workflow with manual build mode for java-kotlin
- Render agent plan & todo items inside live delegation widget

* ci(codeql): add advanced CodeQL workflow with continue-on-error for Kotlin 2.4.10 (#736)

* ci(codeql): use build-mode none to prevent Kotlin 2.4.10 extractor error (#736)

* revert(ci): remove codeql workflow (#736)

* refactor(chat): remove inline task card and rely on sticky top bar (#736)

- Keep StickySubagentBar and SubagentInspectionSheet for top widget UI

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-29 18:15:39 +03:00
M57andM57 8ba92f2567 feat(chat): interactive diff view for file edit and patch tool output (#738) (#741)
* feat(chat): interactive diff view for patch and file edit tool output

* fix(chat): address agy review feedback for diff viewer UX and layout

* fix(chat): suppress raw text/json when diff view renders and fix sync message ordering race (#741)

* fix(tests): resolve nullable smart-cast errors in ToolBubbleParsingTest

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-29 12:05:56 +03:00
AlexDev_ e39544ebd5 fix: keep transient ticket failures reconnecting (#740)
* fix: keep transient ticket failures reconnecting

* fix: recover rejected websocket sends

* fix: serialize websocket reconnect scheduling
2026-07-28 22:33:12 +03:00
M57andM57 13895585b0 test(chat): lock /init + /focus command.dispatch contract (issues #733 #734) (#735)
Both commands are real backend command.dispatch subcommands (name=="init" / name=="focus", hermes-agent 2b0fb72ac). Mobile already routes them generically via RpcDispatch (name/arg/session_id) and handles responses (type:send -> submitPrompt for /init, type:exec -> assistant message for /focus) + surfaces them from commands.catalog.

Adds 6 contract tests mirroring the existing /help + /queue + /status cases:
- /init and /focus forward name+arg+session_id to COMMAND_DISPATCH
- /init with extra arg and /focus on/off forward the arg
- type:send (init) submits the returned prompt via sendMessage
- type:exec (focus) surfaces output as an assistant message

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-27 20:05:22 +03:00
M57 5c1492a80e feat(chat): add animated GIF rendering with tap-to-play animation (#721) (#731)
* feat(chat): add animated GIF rendering with tap-to-play animation

Closes #721

* fix(#721): add missing GifImageThumbnail component and format ktlint

* fix(analytics): resolve ktlint indentation error in itemsIndexed

* feat(chat): add interactive play/pause toggle for animated GIFs

* fix(chat): control GIF playback via Animatable start and stop
2026-07-27 18:32:45 +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 a1155e8015 feat(#724): full gateway-file support — attach MEDIA: files (images, audio, video, docs) (#725)
* fix(#724): inline host-path MEDIA: images on mobile (client-side)

The previous approach re-fetched the REST session-messages endpoint, but that
endpoint returns the raw MEDIA:<path> text (api_server._message_response does
not inline, and the transcript stores raw content). The desktop app instead
reads the file locally via readFileDataUrl because it is co-located with the
host. Mirror that on mobile: on assistant message.complete/done, inline any
MEDIA:<path> image into a base64 data:image/... markdown tag the renderer can
show. Mobile-only, backend untouched. Unreachable tags are stripped (graceful
degrade). Pure inlining logic extracted to MediaInline for unit testing.

* fix: correct RegexOption name (IGNORE_CASE) in MediaInline

* test: fix byte literal types in MediaInlineTest

* fix(#724): rewrite host-path MEDIA: to gateway /api/files/download URL

Pivot from the earlier base64-inline approach to the desktop-proven,
remote-phone-working fix: rewrite MEDIA:<path> directives (delivered raw over
the WS stream) into authenticated
![image](<baseUrl>/api/files/download?path=<enc>&token=<enc>) markdown tags,
mirroring desktop media.ts mediaExternalUrl. The mobile renderer (MarkdownText)
shows http(s) ![]() via Coil, so agent-sent images now render on mobile too —
including on a remote phone (real HTTP, unlike a host-local file read).
Mobile-only, backend untouched. Pure rewrite logic in MediaUrlRewriter
(replaces MediaInline) with unit tests.

* feat(#724): full gateway-file support — attach MEDIA: files (not just images)

Pivot the MEDIA: fix from image-only URL rewriting to real attachments:
- Attachment: add gatewayUrl + AttachmentSource (LOCAL/GATEWAY) so
  gateway-hosted files are first-class and render via existing InlineAttachment.
- HostMediaExtractor (replaces MediaUrlRewriter): parse ANY absolute MEDIA:
  directive (any extension) and strip them from message text.
- ChatViewModel.attachHostMedia: build the authed /api/files/download URL via
  GatewayFileClient, classify via MediaTypes, attach as Attachment (images
  inline, every other file tappable). Idempotent.
- InlineAttachment: tappable. Gateway files fetch via GatewayFileClient and
  share via a chooser Intent (FileProvider); images load the URL in Coil.
- Hence agent-delivered images, audio, video, CSV, PDF and arbitrary files all
  work on mobile — including a remote phone. Backend untouched.

Builds on #726 (GatewayFileClient). Rebased onto main after #726 merged.

* fix(#724): resolve imports, bubble callback scope, and quoted media path regex

* feat(chat): open attachment in-place on tap (#724)

Tap opens LOCAL (content://) and GATEWAY (fetch->FileProvider) files via
ACTION_VIEW; failures surface as a non-blocking snackbar via openError.

* fix: remove redundant out-of-order io.mockk.* wildcard import (ktlint CI)

ktlint import-ordering requires lexicographic order; the trailing wildcard
'import io.mockk.*' sorts before 'io.mockk.coEvery' and was redundant — every
mockk symbol is already explicitly imported, and the single mockkConstructor
call uses a fully-qualified io.mockk. prefix (needs no import).

Fixes ktlint failure on PR #725.

* fix(ci): mock android.jar Intent stub so openAttachment GATEWAY test passes

android.jar unit-test stubs Intent.setDataAndType/addFlags to throw
'... not mocked'. openWithView builds an Intent and calls them, so
openBytes' runCatching swallowed the exception and startActivity never
fired, failing the test. Mock the Intent constructor + setters and
capture the constructed instance to assert the ACTION_VIEW contract.

* fix(#724): resolve host media download URLs and deduplicate REST synced chat bubbles

* fix(#724): add FLAG_ACTIVITY_NEW_TASK for file intents and update GatewayFileClientTest

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-27 14:52:49 +03:00
M57andM57 439ae9fcba feat: gateway files client — fetch any gateway-hosted file over HTTP (#726)
* feat: gateway files client — fetch any gateway-hosted file over HTTP

Add GatewayFileClient: a typed client for the gateway's managed-files
download endpoint (GET /api/files/download?path=<enc>&token=<enc>), which
streams the raw bytes of any file living on the gateway host. This is the
mobile equivalent of desktop's mediaExternalUrl() and works on a remote
phone (real HTTP, not a host-local file read).

- buildDownloadUrl(): pure authed-URL builder (null on blank creds /
  non-absolute path), mirrors desktop's token-in-query auth.
- fetch(): OkHttp GET via the shared client, maps 401/403/404/413 to typed
  GatewayFileResult variants; returns GatewayFile(name, mime, bytes).
- MediaTypes: IMAGE/AUDIO/VIDEO/FILE classification by extension (mirrors
  desktop MEDIA_BY_EXT) so future consumers can present images, audio,
  video, or generic files — beyond just images.

Pure builders/classifiers are unit-tested. Mobile-only, backend untouched.
This is the foundation PR; issue #724's MEDIA: rendering will consume it.

* fix: decode filename + handle download-URL in mediaNameFromPath

- GatewayFileClient.parseFilename now URL-decodes the captured value so
  filename*=UTF-8''a%20b.png yields 'a b.png'.
- MediaTypes.mediaNameFromPath extracts the filename from the percent-encoded
  path= query param of a full /api/files/download?path=<enc> URL (the real
  case), not just a bare path.

* fix(remote): fix unit tests for gateway file client and media parsing

Replace plus-encoded spaces with %20 in download URL builder, URL-decode filenames in parseFilename, and extract target filename from URL path parameters.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-26 21:02:26 +03:00
M57andM57 50fdc12992 feat(chat): render images + attachments in assistant bubbles (#720)
Assistant messages could not display inline images or attachments on the
mobile client. The markdown renderer had no image block, and
AssistantBubble only rendered MarkdownText + timestamp, ignoring
message.attachments (only UserBubble did).

- Add MdBlock.Image + standalone ![alt](uri) parsing in MarkdownText.
  Accepts http(s) gateway URLs and data:image/...;base64 inline media.
- Render message.attachments inside AssistantBubble (mirrors UserBubble)
  via InlineAttachment / AsyncImage.
- Add parser unit tests for image blocks (url, data: URL, inline-in-paragraph).

Local verification: ./gradlew ktlintCheck compileDebugKotlin assembleDebug
all green; app-debug.apk produced.

Fixes feature gap where agent-delivered media (e.g. MEDIA:/ images
sent to chat) was invisible in the Android app.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-26 17:48:03 +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
M57andM57 c181d1d593 Adopt session.redirect (issue #710) (#714)
* Add session.redirect affordance (issue #710)

Route send through session.redirect while a turn is streaming and the prompt
is plain text (no attachments, not a /command); otherwise prompt.submit.
Adds WsMethods.SESSION_REDIRECT + HermesWsClient.sendRedirect. No separate
button — the composer already sends mid-turn, so send just does the redirect.

* fix(chat): capture typing state before sendMessage for redirect

Capture wasStreaming state before updating uiState.isAgentTyping so normal sends call prompt.submit.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-25 18:15:39 +03:00
M57andM57 4e2fb05077 fix(chat): preserve reasoning across streaming transitions (#709)
Flush throttled reasoning before state-transition events (MessageStart/
Complete/Done/ToolStart) so a reasoning delta arriving just before a
transition is not dropped. Also compare reasoningText in sameMessages so
a reasoning-only restore actually re-merges.

- ChatStreamingController.flushPendingReasoning() forces a synchronous flush
- handleWsEvent calls it for the transition events
- sameMessages now includes reasoningText in its equality check
- tests: ChatStreamingControllerTest (flush + no-resurrect), reasoning survives
  MessageDone after an unflushed delta

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-25 13:03:46 +03:00
M57andM57 8c13f9bed0 perf: optimize synchronous I/O in AuthLoginViewModel (#699)
* perf: optimize synchronous I/O in AuthLoginViewModel

Replace blocking OkHttp .execute() with a suspending .await() extension
(CallExt.kt) backed by suspendCancellableCoroutine, freeing Dispatchers.IO
threads during network waits. Probe/connect paths made suspend.

- Call.await() guards both onResponse/onFailure with continuation.isActive
  to avoid AlreadyResume on cancellation.
- Removes non-asserting AuthLoginViewModelBenchmark timing dump.

* perf: optimize synchronous I/O in AuthLoginViewModel

Replaced the blocking `.execute()` OkHttp calls with a suspending `.await()` extension function inside `AuthLoginViewModel`. This frees up the `Dispatchers.IO` threads by deferring the blocking operations to OkHttp's internal task runner, providing a safer async approach and preventing Thread pool starvation on slow network responses.

Modified `probeDashboardInternal` and `connectBasicAuth` to be proper `suspend` functions and resolved corresponding unit tests.

* fix(test): add missing import io.mockk.any in AuthLoginViewModelTest

* fix(test): fix mockk imports and Log matchers in AuthLoginViewModelTest

* fix(test): use persistent mockProbeClient in AuthLoginViewModelTest

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-23 22:23:53 +03:00
M57andM57 152b7d5c0d fix(#700): omit Bearer header on gated-mode REST calls (#701)
In gated mode (basic-auth cookie flow) the dashboard 401s any request that
carries an Authorization: Bearer header alongside the valid session cookie
(verified live 2026-07-23). ApiClient.authInterceptor stamped the WS ticket
as a Bearer header on every REST call, so every non-chat tab (skills, cron,
config, tools, mcp, ...) failed with "token expired" while the WS chat
(?ticket=) kept working.

- Add AuthManager.isGatedMode() (wsAuthParam == "ticket").
- authInterceptor skips the Bearer header in gated mode; loopback/token
  behavior unchanged.
- TokenRefreshAuthenticator bails in gated mode instead of attempting the
  loopback-only SPA token refresh.
- ApiClientTest: add gated/loopback regression tests. 7/7 pass, ktlint clean.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-23 14:46:36 +03:00
M57 b766574627 Add comprehensive tests for loadSettings in SettingsViewModel (#697)
* test: add comprehensive tests for loadSettings in SettingsViewModel

Adds `testLoadSettings_loadsAllProperties` to `SettingsViewModelTest.kt` to thoroughly verify that `loadSettings()` correctly maps all required settings properties from `AuthManager` to the `uiState`. The test utilizes `runTest` and `advanceUntilIdle()` to properly handle testing the coroutine launch in the ViewModel's init block.

* test: add comprehensive tests for loadSettings in SettingsViewModel

Adds `testLoadSettings_loadsAllProperties` to `SettingsViewModelTest.kt` to thoroughly verify that `loadSettings()` correctly maps all required settings properties from `AuthManager` to the `uiState`. The test utilizes `runTest` and `advanceUntilIdle()` to properly handle testing the coroutine launch in the ViewModel's init block. Fixes ktlint formatting issues on imports.
2026-07-23 13:17:00 +03:00
M57 1dd59efaab [Add edge case tests for DashboardSessionTokenRefresher] (#694)
* Add edge case testing for DashboardSessionTokenRefresher

Added tests to `DashboardSessionTokenRefresherTest.kt` to cover
cases where `refresh()` catches exceptions or where `fetch()`
returns null. Included a happy-path test for `refresh()`.

* Add edge case testing for DashboardSessionTokenRefresher

Added tests to `DashboardSessionTokenRefresherTest.kt` to cover
cases where `refresh()` catches exceptions or where `fetch()`
returns null. Included a happy-path test for `refresh()`.
2026-07-23 13:15:23 +03:00
M57 d3836ace7f [Testing Improvement] Add mapping tests for ChatMessageEntity (#693)
* 🧪 test: Add mapping tests for ChatMessageEntity to/from ChatMessage

* Added comprehensive tests to `ChatMessageMapperTest.kt` verifying the pure mappings between Room database entities (`ChatMessageEntity`) and UI models (`ChatMessage`).
* Verified fallback logics for enums (MessageRole and ToolStatus).
* Ensures regression prevention when underlying data models change.

* 🧪 test: Refactor mapper boolean assertions to assertTrue/assertFalse

* Changed assertEquals(true/false, isStreaming) to assertTrue/assertFalse
as suggested in the review.
2026-07-23 13:14:12 +03:00
M57 beb71b9e7a Hoist repeated regex compilations in Markdown parsing (#695)
* perf: Hoist regexes in MarkdownText.kt to avoid recompilation

Resolves a performance issue where `bulletRe`, `taskRe`, and `orderedRe`
were instantiated and compiled inside the `parseBlocks` function on every
call, causing unnecessary overhead during repeated markdown parsing (e.g.,
when chat messages stream in).

These regex objects have been hoisted to file-level `private val`
constants (`BULLET_RE`, `TASK_RE`, `ORDERED_RE`), ensuring they are compiled
only once when the class is loaded.

A benchmark `MarkdownTextBenchmark.kt` was added to verify the
performance change, showing an approximate 50ms improvement over 10k
iterations.

* perf: Hoist regexes in MarkdownText.kt to avoid recompilation

Resolves a performance issue where `bulletRe`, `taskRe`, and `orderedRe`
were instantiated and compiled inside the `parseBlocks` function on every
call, causing unnecessary overhead during repeated markdown parsing (e.g.,
when chat messages stream in).

These regex objects have been hoisted to file-level `private val`
constants (`BULLET_RE`, `TASK_RE`, `ORDERED_RE`), ensuring they are compiled
only once when the class is loaded.

A benchmark `MarkdownTextBenchmark.kt` was added to verify the
performance change, showing an approximate 50ms improvement over 10k
iterations.

* perf: Hoist regexes in MarkdownText.kt to avoid recompilation

Resolves a performance issue where `bulletRe`, `taskRe`, and `orderedRe`
were instantiated and compiled inside the `parseBlocks` function on every
call, causing unnecessary overhead during repeated markdown parsing (e.g.,
when chat messages stream in).

These regex objects have been hoisted to file-level `private val`
constants (`BULLET_RE`, `TASK_RE`, `ORDERED_RE`), ensuring they are compiled
only once when the class is loaded.

A benchmark `MarkdownTextBenchmark.kt` was added to verify the
performance change, showing an approximate 50ms improvement over 10k
iterations.
2026-07-23 13:13:08 +03:00
M57 dcf26f1a16 Add comprehensive tests for ServerCollectionOps (#696)
* Test ServerCollectionOps data manipulation utilities

Added comprehensive unit tests for `ServerCollectionOps.kt` to cover all scenarios for `addOrUpdate`, `selfHealed`, and resolution properties (`resolvedBaseUrl`, `resolvedHost`, `resolvedPort`). Also updated the function name `addOrReplaceServer` to `addOrUpdate` to match the intended signature in the issue description, and updated affected call sites.

* Fix ktlint formatting issues and ensure build succeeds

Fixed ktlint errors in `ServerCollectionOpsTest.kt` by correctly formatting arguments and expressions to comply with project style guidelines. Ran `assembleDebug` and tests locally to verify build health.
2026-07-23 13:04:10 +03:00
M57 992de56059 [Test] Add unit tests for ChatPersistenceRepository (#692)
* test: add unit tests for ChatPersistenceRepository

Added ChatPersistenceRepositoryTest to verify the repository
correctly delegates to the ChatMessageDao and maps between
ChatMessage UI models and Room entity objects. Uses the
existing FakeChatMessageDao for reliable in-memory testing.

* test: fix ktlint issues in ChatPersistenceRepositoryTest

Formatted ChatPersistenceRepositoryTest to comply with standard
ktlint rules for the project.

* test: enhance ChatPersistenceRepositoryTest coverage

Added role and content assertions to `persistMessages_upsertsAllToDao`
and added a new `loadMessages_filtersBySessionId` test to verify
session isolation per PR feedback.
2026-07-23 13:00:16 +03:00
M57 7927944198 Revert "fix(#688): route expired auth sessions to sign-in (#689)" (#698)
This reverts commit a5baf41545.
2026-07-23 12:40:29 +03:00
M57 a5baf41545 fix(#688): route expired auth sessions to sign-in (#689)
* fix(#688): route expired auth sessions to sign-in

Adopt global 401 latch, WS disconnect, and Back/profile guards.

* chore: clean up formatting noise

* fix(ktlint): reorder imports to place java and kotlinx at end

* fix(ktlint): place java.io.IOException at end of imports

* test: fix NavBackStack type parameter in AuthenticationRoutingTest

* test: add AuthSessionState tests to AuthLoginViewModelTest

* fix(ktlint): fix import order in AuthLoginViewModelTest
2026-07-23 00:44:17 +03:00
M57andM57 def122ab58 fix(#686): implement robust history pagination (#687)
* fix(#686): implement robust history pagination

Honor server-returned offset, degrade gracefully on older servers, and parse JSON tool results.

* fix(ci): repair unit-test compilation and pagination test mocks

- Remove trailing blank line in SessionMessagesResponse.kt (ktlint)
- Replace unresolved io.mockk.eq/coAnswers usage in ChatViewModelTest with
  an ordered-call counter inside coAnswers, which compiles and runs on
  MockK 1.14.11 (eq is a matcher-scope member, not a top-level import)
- Align HermesApiServiceMockWebServerTest with the PR's JsonElement content
  model (contentText) and the new include_compacted=true default query param

All unit tests pass locally with the ByteBuddy agent preload.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-23 00:30:48 +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 1cca7764cc perf: optimize data loading across Config, System, Profiles, and Billing tabs (#678)
* perf(model): optimize model tab load speed with two-phase fetch and loadQuick

* perf: optimize data loading across Config, System, Profiles, and Billing tabs

* chore: remove leftover bottom nav references and unused updatePrimaryScreens

* style: fix ktlint violations in Config/Profiles/Billing ViewModels

- property/argument-list wrapping for async(...) calls
- multiline-expression wrapping for async blocks
- remove unused import in ConfigViewModel
- break long error-message string literals to stay under 120 cols

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-22 15:03:30 +03:00
M57andM57 56de3323f0 fix(chat): disable unusable history pagination (#676)
When the initial REST page returns empty messages, hasOlderMessages
was still set to true (offset > 0), causing an infinite loop of empty
load-more requests. Guard with && chatMessages.isNotEmpty().

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-22 12:09:29 +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
M57andM57 e44a53366b Fix #647: resolve Default profile URL from top-level login URL (#649)
* Fix #647: resolve Default profile URL from top-level login URL

ConnectionProfile.resolvedBaseUrl (used directly by ConnectionSection for
the profile list display) silently fell back to the hardcoded loopback
127.0.0.1:9119 whenever a profile's own baseUrl was null, instead of using
the URL the user actually authenticated against.

- Add ConnectionProfile.resolveBaseUrl(topLevelBaseUrl) that prefers the
  profile's own baseUrl, then the top-level login URL, then legacy loopback.
- Store-level ServerCollectionOps.resolvedBaseUrl now applies the same
  fallback so a null-baseUrl profile still shows the real gateway.
- ConnectionSection, SettingsViewModel and ConnectViewModel resolve against
  AuthManager.getBaseUrl() so the loopback fallback can never surface.
- Add Issue647ProfileUrlTest covering login-stamp, null-baseUrl fallback,
  own-URL precedence, fresh-install loopback, and store-level resolution.
- Update existing AuthManagerTest assertions to the new property form.

* Fix #647 (root cause): cache login URL at top-level baseUrl

The previous change only made profile resolution prefer the top-level
login URL, but setBaseUrl() never actually wrote the top-level baseUrl
field — so it stayed DEFAULT_BASE_URL (https://127.0.0.1:9119/) forever.
A Default profile seeded with the loopback default (or whose own baseUrl
was never stamped) therefore still displayed the loopback URL after
logging in via a custom LAN address.

setBaseUrl() now also stamps the top-level baseUrl, making it the
authoritative "last successful login URL" cache. getBaseUrl() and the
store-level resolvedBaseUrl (profile-list display) now reflect the URL
actually authenticated against.

Add regression tests mirroring the exact AuthLoginViewModel.connect()
sequence (seed with loopback -> setBaseUrl(loginUrl) -> token) asserting
the Default profile, top-level baseUrl and store display all resolve to
the login URL, never loopback.

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-20 22:03:31 +03:00
M57andM57 c8a5055a84 fix(auth): support __Host-/__Secure- prefixed session cookies and proxy-relative paths (issue #635) (#648)
- CookieSerialization: add SESSION_COOKIE_NAMES (__Host-, __Secure-, bare) and
  isSessionCookieName(); isSessionCookie now matches any variant.
- PersistentCookieJar.getSessionCookieValue(): iterate SESSION_COOKIE_NAMES,
  returning the strictest prefixed match when present.
- HermesWsClient.refreshWsTicketIfNeeded(): drop the bare-name session-cookie
  preflight in gated mode — the server may hold a valid refresh cookie under a
  prefixed name; the authenticated ticket endpoint is the authority. Ticket URL
  already derived from endpoint (issue #634).
- HermesWsClient: stop logging WS close/failure reason strings and exception
  messages (class name only) to avoid leaking server context / URLs.
- ProfileScopeInterceptor: match profile-scoped paths via endpoint-relative
  request path so reverse-proxy prefixes (e.g. /hermes/api/...) are honored;
  wrap endpoint read in runCatching.

Verified: ktlintCheck, testDebugUnitTest, lintDebug all green locally.

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-20 20:56:39 +03:00
M57andM57 e18b25d065 fix(ux): keep Settings highlighted in bottom nav on drill-down sub-pages (issue #637) (#643)
* fix(ux): keep Settings highlighted in bottom nav on drill-down sub-pages (issue #637)

Map Settings drill-down destinations (SettingsConnection, SettingsAppearance, SettingsChat, SettingsNavBar, SettingsBehavior, SettingsAbout) back to SettingsScreen for bottom-nav selection only - navigation is unaffected.

- Add selectedBottomNavDestination() helper in Navigation.kt
- Extract bar-height logic into Modifier.bottomNavigationHeight() (ICON_AND_TEXT now uses heightIn(min=80.dp) so the standard bar can grow)
- BottomNavigationSelectionTest (JVM): selection mapping logic
- BottomNavigationLayoutTest (androidTest): per-mode bar height assertions

* docs(navigation): warn that new Settings sub-pages must be added to settingsDestinations

Addresses agy review (PR #643): documents the fragility of the hardcoded settingsDestinations set so future Settings drill-down screens don't silently drop the bottom-nav highlight. The sealed-class/marker-interface refactor was declined as scope creep (issue #637 explicitly scopes Settings-only; keys are flat data objects).

* fix: language selector

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-20 18:26:05 +03:00
M57andhy4ri 26b104b2bb [feature] issue #634: HTTPS + reverse-proxy server endpoint support (#638)
* [feature] issue #634: HTTPS + reverse-proxy server endpoint support

- Add ServerEndpoint model (scheme-aware base URL, resolve/webSocketUrl/relativeRequestPath, cleartext policy)
- AuthManager: baseUrl is canonical; setHost/setPort derive from endpoint; endpointForBuild() for scheme-aware URLs
- ApiClient/CookieManager/HermesWsClient: route through ServerEndpoint (no hardcoded http://)
- Data migration: ServerUrlMigration converts legacy host/port to baseUrl
- UI: Connect/AuthLogin/Settings now use a single Server URL field with transport warning
- Build: ALLOW_CLEARTEXT buildConfigField + manifest placeholder for dev
- Tests: ServerEndpointTest + ServerUrlMigrationTest; adapt existing suite to baseUrl API

Co-Authored-By: M57 <Hy4ri@users.noreply.github.com>

* fix(authlogin): basic-auth derives token automatically, no manual entry

connectBasicAuth() built a fresh OkHttpClient for the password-login call and
a different one for the ws-ticket call, so the Set-Cookie from
/auth/password-login (the session token lives in the cookie, not the JSON body)
never reached /api/auth/ws-ticket. That 401'd the ticket request, returned null,
and the UI fell back to asking the user to paste a token.

Both calls now use the shared OkHttpProvider.probe client (same CookieManager
jar), so the session cookie flows through and the WS ticket mints automatically —
matching the dashboard contract (routes.py: password-login sets Set-Cookie,
ws-ticket requires it).

Add AuthLoginBasicAuthFlowTest proving the cookie propagates on the shared
client (and that ws-ticket 401s without it).

Fixes the basic-auth token prompt reported against issue #634.

* chore: remove TASK-634-TESTS.md (stray task-tracking doc, not part of feature)

* ci: broaden .gitguardian.yml test-file ignores (unit + instrumented + variant dirs)

* feat(authlogin): smart auth-mode detection from /api/status + drop pairing-string login

- Replace 302-sniff probe with authoritative GET /api/status read
  (auth_required + auth_providers) to derive DashboardAuthMode.
- Add OAUTH mode (stubbed, 'coming soon' UI, TODO tracks issue #639).
- Render login UI by detected mode: token (loopback), username+password
  (basic/gated), or OAuth notice.
- Remove legacy pairing-string box + onPairingString/applyLegacyPairing.
- Extract internal deriveAuthMode() + add 5 unit tests.

Closes nothing; OAuth tracked in #639.

* fix(chat): don't block WS connect on empty token in gated (ticket) mode

connectWebSocket() gated on getToken() != null, correct for loopback
(token) mode but wrong for gated (ticket) mode: the ticket is minted
fresh by HermesWsClient.refreshWsTicketIfNeeded() from the persisted
session cookie, so getToken() is expected empty at connect time. The
early return left the WS unopened -> DISCONNECTED -> chat showed
'reconnect' immediately after basic-auth login.

Only require a non-blank token in non-gated mode.

Fixes #640.

* test: remove onPairingString test cases for deleted pairing-login path

The pairing-string login (onPairingString/applyLegacyPairing) was removed
from ConnectViewModel, so the 5 tests that exercised it (3 in
ConnectViewModelTest, 2 in E2eIntegrationTest) no longer compile. Remove
them. Also drop the now-orphaned connect_pairing_string from values-ko.

Fixes the Unit & Integration Tests CI failure on PR #638.

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-07-20 17:26:10 +03:00
M57andhy4ri b3765bd106 feat: in-app display language switcher (Settings → Appearance) (#604)
Add a Language selector to Settings → Appearance that overrides the app
display language independent of the device locale. Persists a BCP-47 code
in ServerStoreState.appLanguage; MainActivity.attachBaseContext applies it
via a ContextWrapper (works on minSdk 26, no AppCompat dependency needed
since the Activity is a plain ComponentActivity). Selecting a language
recreates the activity so resources re-resolve immediately.

Includes System default / English / 한국어 options, plus a unit test for
the locale-code resolver.

Note: the new string keys (settings_item_language, language_system,
language_english, language_korean) should be backfilled into values-ko
once PR #602 lands.

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-07-15 23:57:39 +03:00