Commit Graph
186 Commits
Author SHA1 Message Date
M57andM57 6d8cf09d9e refactor(chat): remove StickySubagentBar, rely on in-chat todo card (#811)
The sticky top bar duplicated the todo tool's in-chat card and depended on
the broken state.todos gate. The bubble (TodoTaskCard) already renders
todos from tool message content, so the bar is redundant. Subagent state
remains available via the in-chat inspection sheet.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-04 18:11:25 +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 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
M57andhy4ri 1daa03d686 refactor(chat): tool display cleanup — split ChatBubble.kt god file, drop dead code (#762)
* refactor(chat): extract tool display subsystem from ChatBubble.kt

ChatBubble.kt was a 2578-line god file. Split it into four files in the
same package (zero import churn, no behavior change):

- ChatBubble.kt: user/assistant/system bubbles + dispatch + shared
  helpers (formatTimestamp, buildHighlightedString, InlineAttachment)
- ParsedToolData.kt: the parsed tool payload model
- ToolResultParser.kt: parseToolOutput + per-tool formatters + the
  Gson-compat JSON shims and tolerant exit_code parser
- ToolBubble.kt: ToolBubble + ExpandedToolContent + HeaderRow +
  SecurityRiskChip + CopyButton

formatTimestamp became internal (shared with ToolBubble.kt).
Verified: ktlintCheck + testDebugUnitTest green locally.

* refactor(chat): drop dead ParsedToolData fields and unused shim

Verified via grep: parsed.args / parsed.result / parsed.stderr /
parsed.isRunning / parsed.toolName had zero readers across app/src
(main + test + androidTest). toolName + isRunning are asserted by
ToolBubbleParsingTest, so they stay; args/result/stderr were built by
every formatter (21 construction sites) and never read — removing them
also kills a JSON.stringify-per-tool-complete wasted map build.

keySet() shim had no call sites. formatTodoToolOutput's args param
became unused with the field removal — dropped it too.

Verified: ktlintCheck + testDebugUnitTest green locally.

* refactor(chat): drop unused lastSessionId and ToolBubble isDarkTheme param

ChatScrollController.lastSessionId: introduced in #683, never read or
written since (namefreq scan + git log -S confirm single occurrence at
declaration). Session-change detection moved to lastTailKey/
lastMessageCount.

ToolBubble.isDarkTheme: never read in the body (MaterialTheme +
statusColors do the theming); single call site updated.

Verified: ktlintCheck + compileDebugKotlin green locally.

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-08-01 22:01:12 +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 698d2f6c3f fix(chat): fetch context usage immediately on session change/connect (#746)
Clears stale context token states on session switch/creation and fetches

fresh context usage info immediately instead of waiting for the 5s poll.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-30 11:37:36 +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
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
M57 c7f7d1dd45 fix(#728): resolve theme color leakage for agent chat bubbles (#730)
Use MaterialTheme.colorScheme tokens for Assistant, System, and Tool bubbles so that all presets and dynamic colors render properly without theme bleed.
2026-07-27 15:42:57 +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
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 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 bc6c8a2e68 fix(chat): render reasoning card inside bubble and preserve persistence (#691)
* fix(chat): render reasoning card inside bubble and preserve persistence

Move ReasoningCard inside AssistantBubble and preserve reasoningText across REST re-fetches, MessageStart, and buffer flushes.

* style(chat): resolve ktlint unused imports and blank line formatting
2026-07-23 12:04:18 +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
M57 06ba3e8399 fix(#682): add last-item index guard to chat isAtBottom check (#685) 2026-07-22 22:34:44 +03:00
M57andM57 cc8f8a2773 fix(#682): centralized chat scroll controller with bottom-follow + unread (#683)
Introduce ChatScrollController owning all chat scroll behavior:
- continuous bottom-follow intent tracked from LazyListState (not only on data change)
- pixel-precise bottom detection (last item edge vs viewport, small tolerance)
- streaming follow that pauses on upward scroll and never forces the reader down
- tail updates keyed on messages/streaming/thinking/subagent/clarify
- unread (new-message) count shown on the scroll-to-bottom FAB, cleared on tap
- paging anchor preserved by message id across older-history prepend
- all scroll commands (send/FAB/session switch/search/paging) serialized via one scope

Closes #682

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-22 22:26:49 +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 0bb418b4a7 ui: input bar reasoning chip tweaks (#673)
* ui: input bar reasoning chip tweaks

- Remove emoji from reasoning level labels (dropdown + chip text)
- Reduce reasoning chip horizontal padding (8dp -> 4dp)
- Move reasoning chip to right side (after spacer, next to mic)
- Increase model chip minimum width (120dp -> 140dp)
- Remove unused width import

* fix(ui): add missing widthIn import in ComposerToolbar

Resolves 'Unresolved reference widthIn' compile error at
ComposerToolbar.kt:95 that broke CI (unit-tests, android-lint,
instrumented-tests all fail on compileDebugKotlin).

* ui(chat): enhance input bar layout, reasoning menu header, and card shadow

Give ChatInputBar a floating rounded container with shadow contrast. Make model chip take available space and add header to reasoning menu.

* fix(ui): remove trailing blank line in ComposerToolbar for ktlint

Fix standard:no-consecutive-blank-lines ktlint violation.

* ui(model): enhance ModelPickerDialog with rounded cards and provider badges

Apply card styling, rounded corners (24dp dialog, 12dp cards), provider badges, and shadow contrast to ModelPickerDialog.

* feat(model): add in-dialog model pinning to ModelPickerDialog

Add pin/unpin icon buttons to model cards in ModelPickerDialog across both ModelScreen and ChatScreen.

* ui(model): remove provider badge chip and eliminate bottom whitespace in ModelPickerDialog

Replace bottom Cancel button with top-right Close icon to eliminate bottom whitespace. Remove inline provider badge chip from model cards.

* perf(model): optimize ModelPickerDialog load time with lazy items and O(1) set lookup

Lazy-register individual model items in LazyColumn and memoize pinnedSet to prevent synchronous rendering lag when opening dialog.

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-22 14:21:06 +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 c43904b2b8 feat(chat): rich message bubbles with reasoning, code, clarify, subagent cards (#659 #660 #661 #662 #663 #664) (#671)
* feat(chat): rich message bubbles with reasoning, code, clarify, subagent cards (#659 #660 #661 #662 #663 #664)

* fix(chat): render ClarifyBubble at bottom of message list, not as inline SYSTEM message

Fixes two on-device bugs:
1. ClarifyBubble now renders at the very bottom of the LazyColumn
   (after all messages, streaming message, typing indicator, and subagent
   indicators) instead of inline among regular messages.
2. Bubble disappears when an option is selected or dismissed — the
   ClarifyBubble is driven by state.clarifyRequest instead of a
   persisted ChatMessage.clarifyInfo, so clearing the request state
   removes it from the list entirely.

* fix(chat): add text input field to ClarifyBubble for custom typed answers

* fix(chat): render ReasoningCard at list level with stable key, not inside AssistantBubble

* fix(chat): address agy review — hoist regex, fix a11y, cache anim specs, disable animateContentSize while streaming

* fix(chat): replace hardcoded Color() with theme constants to pass CI checkColorLiterals

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-22 02:04:55 +03:00
M57andM57 bb4149eb0d refactor(chat): two-row ChatComposer + model/reasoning chips (#669)
* feat(chat): add reasoningLevel to ChatUiState + ViewModel methods

* feat(chat): add ComposerToolbar with model + reasoning chips

* refactor(chat): rewrite ChatInputBar to two-row layout

* feat(chat): wire ComposerToolbar into ChatScreen

* fix(chat): make model chip fixed 120dp with horizontal scroll for long names

* feat(chat): reasoning chip opens dropdown menu with all 9 levels instead of cycling

* fix(chat): remove Auto level from reasoning menu — backend only supports none..ultra, default is medium

* feat(chat): sync model + reasoning level from backend session.resume response

* fix(chat): handle session.info events for model/reasoning sync + fix null value 4002

* fix(chat): safety timeout for createNewSession + try-catch event loop

* fix(chat): address agy review findings

- Restore InsertDriveFile icon for non-image attachments
- Add widthIn(max=120.dp) constraint on attachment filename
- Restore stringResource reference for remove-attachment description
- Remove dead cycleReasoningLevel() method (dropdown supersedes)
- Fix createNewSession timeout race with generation counter

* fix(chat): update instrumented test for two-row layout

chatScreen_renders_and_acceptsInput had an assertion that the mic
button disappears when text is entered — this was the OLD single-row
layout behavior. The new two-row composer always shows the mic button
in the toolbar row, independent of input state.

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
2026-07-21 18:18:19 +03:00
M57 c16f6e3e0b fix(chat): make chat input composer more compact and flush (#654)
* fix(chat): make chat input composer more compact and flush

* fix(chat): fix chat page compose structure (issue #652)

- Resolve EmptyState rendering alongside empty LazyColumn by wrapping in if/else
- Flatten nested weight(1f) layout by moving search bar to ChatScreen

* style(chat): run ktlint auto-format and fix missing padding import

* style(chat): remove unused imports in ChatComposer and ChatMessageList
2026-07-21 12:52:04 +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 9661246497 audit(#622): sweep hardcoded colors + add checkColorLiterals guard (#626)
Removed docs/color-hardcoded-audit.md per request. Replaced 2 hardcoded
Color literals with theme tokens; added CI guard blocking new literals.

Co-authored-by: Hy4ri <hy4ri@users.noreply.github.com>
2026-07-18 16:15:45 +03:00
M57andM57 0c2b9679b7 refactor(#621): split ChatScreen.kt god file into focused composable components (#623)
Split the 2,267-line ChatScreen.kt into 11 single-purpose files under
ui/chat/components/, keeping ChatScreen.kt as a thin (~530 line)
entry compositor that delegates rendering to the new components.

Files extracted:
- ChatConnectionBanner.kt    — top banner (reconnecting/disconnected/auth-expired)
- ChatMessageList.kt         — message list + search bar overlay + empty state
- ChatComposer.kt            — input bar + attachment chips + slash dropdown
- ChatSearchBar.kt           — CompactSearchInput + SearchBarRow
- ChatDialogs.kt             — ClarifyDialog, SudoPromptDialog, SecretPromptDialog, ReloginDialog
- ChatIndicators.kt          — ThinkingIndicator, ReasoningIndicator, SubagentIndicatorRow
- ChatScrollFab.kt           — scrollToBottom utils + FAB
- ChatLifecycleEffects.kt    — session switch, lifecycle observer, auto-scroll, error snackbars
- ChatReactions.kt           — ReactionHeartsOverlay + FloatingHeart animation
- ChatStreaming.kt           — StreamingBubbleWithTypingEffect
- ChatLoadingOverlay.kt      — full-screen semi-transparent loading overlay

No behavioral change. All composables are public where they need to be
called from ChatScreen, otherwise keep their original visibility.
Unblocks #583 and #584 which were previously too risky to attempt in
the 2.2k-line file.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-18 13:03:04 +03:00
M57andM57 3030e431f2 feat: auto-scroll long chat title once on load + on tap (#612)
* feat: auto-scroll long chat title once on load + on tap

* fix: resolve state delegate + suspension compile errors in AutoScrollingTitleText

* fix: use Int scroll offset for animateScrollTo

* fix: measure real container width for title marquee (was always Infinity)

* fix: ease the title marquee scroll (FastOutSlowInEasing + glided return)

* fix: address agy review — cancel overlapping scrolls + one-shot on-load guard + clickable on box

* fix(ui): smooth out top app bar collapse transition

Use dynamic layout padding based on scroll offset to prevent blank gap at the top of content when app bar collapses.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-07-16 21:45:13 +03:00
M57andM57 d9f20f8eb2 fix(#576): surface command.dispatch 4018 errors instead of silent dead-tap (#603)
* fix(#576): surface command.dispatch 4018 errors instead of silent dead-tap

* fix(#576): fall back to slash.exec for command.dispatch 4018 registry misses

* fix(#576): block cli_only/TUI-only commands at dispatch (blocklist deliverable)

* review(#603): address agy findings — tighten 4018 match, case-insensitive filter, add double-fault + blank-output tests

* fix(#576): surface command dispatch outputs and update tests

---------

Co-authored-by: M57 <m57@hermes-control.app>
2026-07-15 23:43:00 +03:00
M57andm57 e273488adc fix: add breathing room between rendered markdown paragraphs (2dp -> 6dp) (#601)
Co-authored-by: m57 <m57@357357357.xyz>
2026-07-15 12:59:04 +03:00
M57andhy4ri 0823fe3117 fix(#599): place cursor at end of text when a slash command is inserted from the dropdown (#600)
Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-07-15 10:36:18 +03:00
M57andM57 5376d46683 feat: queue prompts while the agent is running (#598)
Allow the chat input to be sent while the agent is mid-turn or awaiting
approval. The backend's prompt.submit busy-input policy already queues any
prompt that lands during a running turn (tui_gateway/server.py:_handle_busy_submit)
and drains it as the next turn, so the message is never dropped.

- ChatInputPolicy: pure, testable send/placeholder decision logic
- ChatScreen: canSend stays enabled while typing/approving; placeholder shows
  'Queued — sends when agent is free' when text is present during a busy turn
- ChatInputPolicyTest: truth-table covering typing, approval-pending,
  disconnected, empty, attachment, and slash-command cases
- strings: chat_input_placeholder_queue

Note: this supersedes the Genesi599 fork's approach (dispatchViaRpc('queue ...')),
which double-submits and relies on a 4018-prone command.dispatch path. The
backend-queued prompt.submit path is the correct, single-submission contract.

Co-authored-by: M57 <m57@hermes-control.app>
2026-07-15 01:46:29 +03:00
M57 d1fc477bcd fix: resolve all build compilation and style check warnings (#597)
* fix: resolve all build compilation and style check warnings

* fix: remove unused import in SystemViewModel
2026-07-14 21:55:37 +03:00
M57andM57 48f5ffbe52 fix(#589): show in-session model picker when user sends /model (#596)
* fix(#589): show in-session model picker when user sends /model

Typing a bare '/model' in the chat composer now opens the model picker so
the user can hot-swap the CURRENT session's model via the existing
slash-command path, instead of hand-typing the provider/model string.

- ChatViewModel.preloadModelOptions() caches GET /api/model/options at
  GatewayReady so the picker opens instantly; falls back to on-demand
  refresh if opened before preload finishes.
- sendMessage() intercepts a bare '/model' (no arg) -> showModelPicker=true
  instead of dispatching; a typed '/model provider/model' still dispatches
  via handleSlashCommand.
- sendSlashModel() routes through command.dispatch with session_id, so the
  swap is session-scoped (not the global POST /api/model/set).
- Extracted ModelPickerDialog into ui/model/components and reused by both
  ModelScreen and ChatScreen (adds loading + empty states).
- Added a top-bar model chip (current session model label) that opens the
  same picker.
- Added unit tests: bare /model opens picker, picker selection hot-swaps via
  slash dispatch with session_id, typed /model with arg dispatches directly.

Note: CI shows 2 pre-existing test failures (ChatViewModelTest.testToggleSearch,
HermesWsClientTest.testAutoReconnect) that also fail on a clean main full
suite (MockK static-mock leakage between classes). Unrelated to this change;
compile + ktlint + the 3 new tests pass.

* fix(#589): use backend-valid session-scoped /model command syntax

The model picker now dispatches the canonical backend command form
'/model <model> --provider <slug> --session' instead of the unsupported
'provider/model' slash form. The '--session' flag keeps the switch scoped
to the current chat only (per-session override) and never writes the global
model config, which matches the backend model-switch contract.

- sendSlashModel builds '/model $model --provider $provider --session'.
- Updated unit tests to assert the corrected arg payload
  ('gpt-4o --provider openai --session') and the fully-typed dispatch path.

* fix(#589): add pinned-models section to in-session model picker

- ModelPickerDialog now renders a Pinned section (mirrors global model screen)
- search filter matches model names too, not just provider name/slug
- ChatUiState gains modelPickerPinned, fed from AuthManager.getPinnedModels()
- top-bar model chip removed; picker opens on /model only
- test mock for getPinnedModels() added

* fix(#589): route /model via prompt.submit instead of command.dispatch (resolves 4018)

/model is a real backend slash command handled in the backend's message
pipeline (_handle_message -> _handle_model_command), so it must arrive as
a normal prompt.submit message, not the command.dispatch RPC (which only
knows quick/plugin/bundle/skill commands and 4018s on /model).

- SlashCommandDispatcher: add SlashResult.ModelSwitch, map /model -> ModelSwitch
- ChatViewModel: handle ModelSwitch -> handleModelSwitch() sends via
  WsMethods.PROMPT_SUBMIT with {session_id, text}
- Tests: rewrite the two /model tests to assert prompt.submit (previously
  asserted the buggy command.dispatch path); add /model -> ModelSwitch test

Independent agy review: APPROVE, no blocking issues.

* fix(#589): route /model via slash.exec (correct transport; prompt.submit made LLM treat it as text)

Earlier attempt routed /model through prompt.submit, but the TUI gateway's
prompt.submit handler does NOT parse slash commands — it runs the text as a
normal agent turn, so the LLM received '/model ...' as a user message (the
exact reported bug). The correct RPC is slash.exec, which runs the command
through the slash worker and _mirror_slash_side_effects -> _apply_model_switch
to hot-swap the live session model. command.dispatch is also wrong (only knows
quick/plugin/bundle/skill commands -> 4018).

- WsMethods: add SLASH_EXEC = "slash.exec"
- ChatViewModel.handleModelSwitch: send slash.exec with {command, session_id}
  (previously prompt.submit with {text}); corrected doc comment
- SlashCommandDispatcher: corrected comments (slash.exec, not prompt.submit)
- ChatViewModelTest: rewrite both /model tests to assert slash.exec with the
  /model <model> --provider <slug> --session command string
- SlashCommandDispatcherTest: /model -> ModelSwitch already covered

Verified against hermes-agent tui_gateway/server.py: slash.exec (L13190) ->
worker.run + _mirror_slash_side_effects (L13299) -> _apply_model_switch (L13118);
parse_model_flags accepts --provider/--session (hermes_cli/model_switch.py:355).

Independent agy review of the prior attempt: APPROVE (the wrong-transport
assumption was the author's, not agy's).

* fix(#589): route /model via config.set key=model (slash.exec 4001'd; prompt.submit made LLM treat it as text)

Third transport attempt, now verified against the gateway source:
- prompt.submit: gateway runs text as a normal agent turn -> LLM sees '/model'
  as a message (the original reported bug). WRONG.
- slash.exec: gateway returns 4001 'session not found'. The slash worker path
  resolves the session differently / requires agent-built state the mobile
  session isn't in at switch time. WRONG.
- config.set key=model: gateway (server.py config.set L10253) routes key=='model'
  straight to _apply_model_switch using the SAME _sessions.get(session_id)
  lookup that the working command.dispatch uses. CORRECT.

sendSlashModel still builds '/model <model> --provider <slug> --session';
parse_model_flags (hermes_cli/model_switch.py:355) extracts model/provider/
session from it. handleModelSwitch now sends config.set {key:model, value:
command, session_id}.

- WsMethods: add CONFIG_SET = 'config.set'
- ChatViewModel.handleModelSwitch: config.set instead of slash.exec
- SlashCommandDispatcher: comments updated (config.set, not slash.exec)
- ChatViewModelTest: both /model tests assert config.set key=model + command
- also fixes the MockK testToggleSearch flake by preloading byte-buddy agent
  via JAVA_TOOL_OPTIONS locally (container blocks JVM self-attach)

* fix(#589): strip /model prefix before config.set key=model (5001 'model names can't have spaces')

config.set key=model passes the value DIRECTLY to parse_model_flags — it does
NOT strip a leading '/model' the way slash.exec / prompt.submit do. Sending
'/model <model> ...' made parse_model_flags treat '/model <model>' as the
model name (contains a space) -> 5001.

handleModelSwitch now strips the '/model' prefix and sends the bare spec
'<model> --provider <slug> --session', matching the TUI client's
modelValueForConfigSet (ui-tui session.ts:23) used by useMainApp.ts:123.

- ChatViewModel.handleModelSwitch: spec = command.removePrefix('/model').trim()
- ChatViewModelTest: assert bare spec 'gpt-4o --provider openai --session'
- comment updated

* fix(#589): strip /model prefix case-insensitively before config.set

SlashCommandDispatcher and isModelPickerCommand match /model
case-insensitively, but handleModelSwitch used removePrefix("/model")
(case-sensitive). A typed /MODEL ... forwarded the literal slash prefix
to config.set and the backend's parse_model_flags wouldn't recognize it
— silent hot-swap failure.

Use startsWith("/model", ignoreCase=true) + substring(6) so any casing
is stripped. Added a regression test asserting /MODEL does not forward a
leading slash.

* fix(test): inject searchDispatcher to fix MockKException in testToggleSearch

---------

Co-authored-by: M57 <m57@hermes-control.app>
2026-07-14 20:39:33 +03:00
M57andhy4ri 71c1bdded9 fix(#585): render terminal tool output via real backend payload keys (#595)
The backend terminal tool (tools/terminal_tool.py) returns a single merged
'output' string plus 'exit_code' and 'error' — never 'stdout'/'stderr'.
ChatBubble was reading the non-existent 'stdout' key, so command output was
silently dropped and only the Duration footer showed.

- Parse 'output' as canonical; keep legacy 'stdout'/'stderr' fallback for
  backward compat.
- Route terminal output into mainOutput so it renders like other tool bubbles.
- Surface non-zero exit codes as an error line when no 'error' string is set.
- Remove dead 'stderr' render branch.
- Update ToolBubbleParsingTest to assert the real payload shape (incl. the
  exact issue-585 'echo hellp' sample) and add regression cases.

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
2026-07-14 12:01:08 +03:00