Commit Graph
135 Commits
Author SHA1 Message Date
M57 aff1b7ed3a fix(chat): use runtime session ID in approval RPCs to prevent 4001 session not found 2026-09-05 13:15:08 +03:00
M57 5ce01e429b fix(bidi): isolate leading and inline English words in RTL paragraphs
- In parseInlineSource, wrap consecutive Latin/LTR tokens in LTR isolates (LRI...PDI)
  when rendering inside an RTL block, preventing leading English terms like "Okay"
  from flipping paragraph layout direction and causing BiDi line shuffling.
- Switch textDirection from TextDirection.ContentOrRtl to explicit TextDirection.Rtl
  when isRtl is true across MarkdownText, ChatBubble, and GroupChatScreen, preventing
  first-strong character fallbacks from overriding resolved paragraph direction.
2026-09-04 23:22:22 +03:00
M57 bd0b4c2d55 feat(chat): full approval.request support (#1057) 2026-09-03 22:33:15 +03:00
M57 077ad1fa81 feat(chat): full sudo.request support with expire, cancel-unblock, secret fields (#1056) 2026-09-03 22:08:07 +03:00
M57 6d2362ba8a feat(chat): support multi-select, batch multi-question clarify prompts, and expiry (fixes #1045) (#1055) 2026-09-03 21:38:57 +03:00
M57 b3439d87c9 feat(chat): support /undo command with transcript reload and composer prefill (#1051) 2026-09-03 19:04:43 +03:00
M57 efe6fe81cb fix(chat): support confirmation dialog when switching to expensive or data-policy models (#1050) 2026-09-03 16:56:08 +03:00
M57 cd8c27e62b feat(chat): support dynamic BiDi Arabic input and isolated mixed text in Markdown (#1047) 2026-09-03 13:06:03 +03:00
M57 fa17c95296 fix(chat): support batch clarify.request payload and route question_id on respond (fixes #1045) (#1046) 2026-09-03 12:50:13 +03:00
M57 dbd7008ee3 feat(chat): support non-Google speech-to-text providers (fixes #1038) (#1042) 2026-09-02 12:31:42 +03:00
M57andM57 11dd1eff92 feat(chat): live subagent steering and cancellation action sheet (closes #1030) (#1034)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-09-01 18:19:00 +03:00
M57andM57 ceb4813218 feat(chat): support hierarchical nested subtasks in Todos via parent property (#1018) (#1028)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-31 22:09:15 +03:00
M57andM57 53c8eca714 feat: adopt side-questions via prompt.btw and /btw command in Chat (fixes #1015) (#1026)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-31 15:54:09 +03:00
M57andM57 cfe3e736fd chore: upgrade ktlint to 1.8.0 and format codebase (#1024)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-31 12:27:40 +03:00
M57andM57 db3f8c736e feat(markdown): support variable-length code fences and nested codeblocks (#1003)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-28 20:27:17 +03:00
AlexDev_ 0ae5ed4db2 feat(chat): stabilize multi-image attachment picker (#986) 2026-08-27 10:50:55 +03:00
AlexDev_ c7d7abf4a1 fix(chat): prevent timeline prefetch ANRs (#985) 2026-08-27 10:47:04 +03:00
M57 b422ead921 feat(chat): add @bot mention autocomplete to ChatComposer (#980) 2026-08-27 01:51:10 +03:00
M57 4e666b49a8 feat(chat): intercept /new in canonical Bot Chat sessions to /compact (#979) 2026-08-27 01:50:43 +03:00
M57andM57 460472f631 fix(chat): queue prompts while session creation is in flight (issue #969) (#970)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-26 06:35:44 +03:00
M57andM57 14b9c1d1d3 fix(chat): support multi-level nested bullet, task, and ordered markdown lists (#965) (#968)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-25 17:41:47 +03:00
M57andM57 5fd732be9b fix(chat): preserve ordered list numbering in loose lists (#959) (#963)
ORDERED_RE only captured item text and discarded the source number, and the
parse loop broke on the first blank line — so loose numbered lists (blank lines
between items) re-indexed every item to 1.

- capture the prefix number and emit it as MdBlock.Ordered.index
- treat a blank line as list-internal when the next non-blank line is another
  ordered item, so loose lists stay one ordered list
- keep sequential numbering for tight lists (1,2,3) and honor custom starts

add regression tests in MarkdownTextFeatureTest (loose, custom-start, tight).

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-24 00:16:27 +03:00
M57 5153e1572f fix(chat): dedupe user bubbles enriched with attachment ref lines (#952)
Sending an image/file renders the user message twice: the optimistic
bubble carries the raw caption while the backend persists an enriched
copy with "@image:" / "@file:" ref lines and a "[screenshot]" marker
(_build_image_ref_message / run_agent multipart flattening). The trimmed
exact-content compare in sameLogicalMessage fails on that drift and the
prefix fallback gate (>=40 chars) rejects short captions, so the REST
transcript sync appends the REST copy as a second bubble.

Compare USER rows on the caption with attachment-injection lines
stripped (stripAttachmentRefLines). USER-only so assistant text that
legitimately mentions these tokens is never collapsed.

Device-verified on the hermes37 emulator: image send + transcript
resync renders a single user bubble, restart/reload stays clean.
Unit: ChatToolDedupeTest 32/32.
2026-08-22 12:33:48 +03:00
M57 4549410a45 chore(ui): replace emoji with Material icons across chat UI (#944)
* chore(ui): replace emoji with Material icons across chat UI

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

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

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

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

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

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

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

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

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

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

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

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

ktlint clean; compileDebugKotlin + chat unit tests green.

* chore: clear pre-existing compile warnings

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

ktlint clean; compileDebugKotlin SUCCESSFUL with no warnings.

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

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

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

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

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

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

android-lint now BUILD SUCCESSFUL (0 errors).

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

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

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

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

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

Adds two focused tests: same-item selection, and pending-only fallback.
2026-08-21 00:03:50 +03:00
AlexDev_ bb4ca6bfa9 feat(chat): render LaTeX formulas natively (#939)
* feat(chat): render LaTeX formulas

Support native inline and display math with accessible Compose rendering.

* perf(chat): reuse LaTeX parsing state

Share one measurer per message and skip redundant math splitting.
2026-08-19 22:41:50 +03:00
M57andM57 caed4db316 fix(chat): route ALL synthetic backend user rows to system events (#937)
The backend persists a whole class of internal scaffolding as plain
role=user rows with NO display_kind (SessionDB strips underscore metadata):
runtime nudges (max tool-call iterations), context injections ([System:/[CONTEXT/
[PRIOR CONTEXT), background/cron/delegation notices ([IMPORTANT: Background/
Cronjob Response:/[ASYNC DELEGATION), out-of-band markers ([OUT-OF-BAND), and
todo/planning injections ([Your active task list/[Planning state preserved).
These are NOT real human turns, but the mobile rendered them as user bubbles.

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

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

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

ktlint + Android Lint + unit tests green locally.

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

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

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

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-18 17:32:26 +03:00
M57andM57 acd82c0085 feat(#919): adopt session.usage WS push event for real-time context & compression updates (#927)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-17 23:31:07 +03:00
M57andM57 75023a2bf0 feat: show a loading indicator on gateway attachments while they open (#917)
Tapping a gateway attachment gave zero feedback while the file
downloaded (a 100MB file could sit there silently for tens of seconds).
Mirror the save spinner: ChatUiState gains openingAttachmentPath, set
when openAttachment starts the download and cleared in a finally, and
the attachment card renders a small CircularProgressIndicator while its
path is being fetched. Wired through FullBleedChatList, ChatBubble and
FullBleedAgentMessage.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-15 23:28:04 +03:00
M57andM57 b653903fa8 fix: stream gateway downloads to disk instead of slurping into RAM (#914)
* fix: stream gateway downloads to disk instead of slurping into RAM

Opening or saving a ~100MB MEDIA file OOMs the app: fetch() read the
whole response body into a single ByteArray (peak heap ~2x file size)
before writing it to cache. A 102MB docx (~97MiB) passed the server's
100MiB cap but blew the phone heap (192MiB growth limit) on both open
and save.

fetch() now streams the body to a cache file in 64KiB chunks and
GatewayFile carries the written file instead of bytes; save copies the
cache file to the destination chunked. Cancellation-safe (partial file
deleted, CancellationException never swallowed), UUID-named cache
files, 24h sweep.

Closes #913

* fix: stub app.cacheDir/applicationContext in attachment tests

The streaming fetch builds File(cacheDir, ...) from the application
context's cacheDir; the affected tests never stubbed it, so the mock
returned a bogus File (null path) and java.io.File's constructor NPE'd
before fetch was even called. Stub cacheDir + applicationContext
(returning app itself, matching the existing success-path test) in the
five open/save tests. copyChunked is also stubbed to copy for real in
the save-writes test since mockkObject mocks the whole object.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-15 23:05:10 +03:00
M57andM57 4be1e965f2 feat: render display_kind timeline markers as chips, not user bubbles (#904) (#909)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-15 14:06:51 +03:00
M57andM57 9c7bb8635f fix(chat): /queue submits with queued=true so the backend queues instead of redirecting (#892)
* fix(chat): /queue submits with queued=true so the backend queues instead of redirecting

The mobile app routed /queue through command.dispatch, whose backend shim
only echoes the text back as a plain send payload. The re-submitted
prompt.submit carried no queued flag, so a busy session applied the
default busy_input_mode (interrupt) and REDIRECTED the live turn instead
of queueing — verified on-device: backend replied {"status":"redirected"}
and the queued message was dropped, never answered.

Intercept /queue and /q client-side and submit prompt.submit directly
with queued=true, which the gateway honors as run-after-never-interrupt
regardless of display.busy_input_mode (hermes-agent methods_prompt.py
_handle_busy_submit). Verified on the emulator: {"status":"queued"},
the running turn completed untouched, and the queued prompt ran as the
next turn and was answered.

* fix(chat): /queue bubble shows the queued text so the sync dedupes the echo

The optimistic bubble for /queue carried the raw command ("/queue foo"),
but the server only ever sees the stripped text ("foo") — the transcript
sync's logical dedupe (sameLogicalMessage) can't match them, so the echoed
user row survived as a duplicate that rendered BELOW its own answer.

QueuePrompt now carries the display content: the stripped queued text (or
the raw command when bare). handleSlashCommand classifies first and uses it
for the optimistic bubble, so local and REST copies match verbatim and the
merge collapses them into one row at the correct position.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-13 12:16:16 +03:00
M57andM57 683a24becf fix(profiles): connection-profile switch re-dials the WebSocket (#888)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-12 23:27:08 +03:00
M57andM57 546bd17b18 fix(chat): snappy in-chat search over visible text only (#887)
* fix(chat): snappy in-chat search over visible text only

- findMatches now scans USER + ASSISTANT messages only — tool rows and
  system events were matched before, so navigation jumped to invisible
  matches and the scan paid for the chat's heaviest strings (tool payloads)
- Reasoning text is explicitly out of scope (lives in reasoningText, never
  highlighted) — documented in the controller contract
- Debounce (150ms) coalesces typing into one scan instead of one full scan
  per keystroke; blanking the query still clears instantly
- Tests: controller scope (user/assistant only, tool/system/reasoning
  excluded, case-insensitive, navigation wrap) + delegate debounce
  (coalescing, cancelled intermediate queries, instant blank clear)

* fix(chat): search navigation lands the match mid-view, not at an edge

scrollToSearchMatch top-aligned the matched message with
animateScrollToItem(index), pinning it to the viewport edge — tall
messages left the highlighted word above/below the visible area.
Now top-aligns, then nudges the item down to ~1/3 viewport height
(verified top-align -> measure -> adjust pattern, same as scrollToBottom).

* fix(chat): search scroll targets the real lazy item and the actual word

ROOT CAUSE: search-match indices are MESSAGE-list indices, but the
LazyColumn has extra items (reasoning hoists, tool rows, loading-older),
so scrollToSearchMatch(index) landed on the WRONG item whenever reasoning
or tools preceded the match — the 'does nothing / word under or above the
view' bug from the start.

- messageIdToLazyIndex(): pure messageId -> LazyColumn item index mapping
  mirroring FullBleedChatList's emission order (verified in tests)
- search-scroll effect moved INTO FullBleedChatList (the only composable
  that knows the mapping), serialized through the same ChatScrollController
- word-level targeting: each SearchMatch now carries its character offset
  in the message; the scroll places offset/contentLength x item height at
  ~1/3 viewport, so the highlighted word itself lands in view on tall or
  multi-occurrence messages
- counter/navigation now count OCCURRENCES (word hits), one entry per hit
  with a parallel searchMatchOffsets list; per-message dedupe removed
- tests: lazy-index mapping (reasoning hoist, tools, leading items),
  SearchMatch offsets, occurrence flattening, regex-literal queries

* fix(ci): pass searchMatchOffsets and scrollController to FullBleedChatList in instrumented test

The render helper in FullBleedChatListTest still used the pre-offset
signature, so compileDebugAndroidTestKotlin failed on the emulator job.
Verified locally with compileDebugAndroidTestKotlin + ktlintCheck.

* perf(chat): allocation-free indexOf search matcher with capped results

- findMatches scans with String.indexOf(ignoreCase=true) on the ORIGINAL
  content instead of Regex(Pattern.quote()).findAll: queries are always
  literal, so regex was pure overhead — a compiled Pattern plus one
  MatchResult allocation per hit. Offsets stay exact (no lowercase
  transform, which can change string length on some scripts).
- returns SearchResult(matches, totalMatches, capped): results are capped
  at 500 stored entries (MAX_SEARCH_MATCHES) to bound allocations and
  navigation on degenerate queries, while the counter still shows the
  exact total (searchMatchTotal/searchMatchCapped in ChatUiState).
- 14 controller tests green, delegate behavior unchanged.

* perf(chat): O(1) current-match lookup and highlight only matched bubbles

Two recomposition hot spots killed in the list renderer:

- isCurrentMatchFor() did messages.indexOfFirst per rendered bubble per
  search update — O(n) per item, O(n^2) per update on the main thread.
  Now currentMatchMessageId() resolves the current match's message id
  ONCE per recomposition and items compare ids (O(1)).
- every prose bubble re-ran its highlight scan on every search-state
  change even with zero hits. matchedMessageIds() computes the matched
  id set once; bubbles outside it get an empty searchQuery and skip the
  scan entirely.

Both helpers are pure, tested in FullBleedTurnsTest (18 tests), and the
instrumented test target still compiles.

* perf(chat): dedicated ChatSearchState holder — search no longer recomposes the list

Search fields lived in ChatUiState, so every debounced scan result changed
the whole state object: ChatScreen recomposed, FullBleedChatList's params
changed, and the entire list body re-ran (turns loop + item registrations)
on the main thread per search update.

Search state now lives in a @Stable snapshot-backed ChatSearchState
(LazyListState pattern) owned by ChatSearchDelegate:
- the list takes ONE stable param; the instance never changes, so the
  body is skipped on search updates — only the scopes that read a field
  recompose (search bar, matched bubbles, the scroll effect)
- matchedIds and currentMatchId are computed once per scan/navigate in
  the delegate and stored as state fields — items do O(1) id lookups
  inside their own lambdas, no body-level derivation
- ChatUiState loses the 7 search fields; ChatViewModel exposes
  viewModel.searchState; delegate tests now assert on the holder

19 search-related unit tests green, androidTest compiles.

* test(chat): long-session search sanity (2000 msgs) — exact counts, valid offsets

Synthetic 2000-message / ~1KB session verifying the matcher at scale:
- frequent word: exact 32_000 total, capped storage, every stored offset
  points at a real occurrence
- unique word resolves to the right message; tool payloads stay excluded
- local measurement: full scan ~7ms (regex version allocates per hit)

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(chat): blinking typing caret while streaming

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

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

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-12 00:22:07 +03:00
M57andM57 1e6e248891 chore: eliminate all Kotlin compiler warnings in main source (#877)
* chore: eliminate all Kotlin compiler warnings in main source

* test(chat): fix cross-class coroutine leaks poisoning the unit suite

- ChatUpdateCommandTest: inject FakeSlashUsageStore — the real DataStore
  store inits on real IO threads with a relaxed-mock app (cacheDir NPE)
  and leaks an uncaught exception into the next runTest class.
- ChatUpdateCommandTest + SlashCommandDispatchRpcTest: mock
  ProfileSwitchCoordinator.switched (ChatViewModelTest pattern) — VMs
  otherwise park collectors on the singleton flow that crash later
  classes (ProfileSwitchCoordinatorTest) on stale-Main resumption.

Full suite: 944 tests, 0 failures (was 2 flaky per run).

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-10 22:56:16 +03:00
M57andM57 b05cffa9c4 feat(chat): /resume and /history open the session history tab (issue #864) (#876)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-10 21:43:33 +03:00
M57andM57 a9c3cd0691 feat(chat): rank slash autocomplete by usage (issue #865) (#874)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-10 19:56:01 +03:00
M57andM57 5ab7ad16df fix(chat): /update handled client-side with progress popup (issue #862) (#873)
* fix(chat): /update handled client-side with progress popup (issue #862)

* test(chat): stub actionProgress controller in ChatScreenTest (issue #862)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-10 19:30:32 +03:00
M57andM57 4a034684bb feat(chat): adopt order=latest for transcript initial load and paging (issue #859) (#870)
Backend now supports order=latest on GET /api/sessions/{id}/messages
(offset measured back from the newest message, page returned
chronologically). The mobile initial load previously computed its anchor
from a possibly-stale session-list message_count, which landed the first
page at the wrong position (dropped history or a duplicated tail).

- HermesApiService.getSessionMessages: add @Query("order")
- Initial load requests the newest page directly (order=latest, offset=0)
  and drops the fetchServerMessageCount offset math; legacy backends
  (no pagination echo) fall back to the count-based anchor
- Backward paging advances the from-end offset by a full page; a short
  page marks the oldest boundary and stops pagination
- ChatMessage stable keys under latest paging use the server's
  AUTOINCREMENT row id (from-end positions shift as the transcript
  grows and would collide across hydrations)
- syncCurrentSession refetches the newest page and merges by row id
  with a logical-content fallback for re-keyed rows

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-10 14:40:20 +03:00
M57andM57 ea8fead087 feat(chat): surface session terminal_backend (ssh/docker) in header (#869)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-10 01:53:47 +03:00
AlexDev_ 3b033a025b fix(notification): disconnect idle background socket (#858)
* fix(notification): disconnect idle background socket

* fix(notification): clear rejected prompt state
2026-08-09 12:06:59 +03:00
AlexDev_ 0db741edfe fix(chat): clear clarify request on completion (#857) 2026-08-08 23:44:23 +03:00
AlexDev_ 98b35ca8bf fix(chat): open the selected history session (#856) 2026-08-08 23:38:07 +03:00
M57andM57 adde03a4de fix(chat): stop duplicating interim commentary before tool calls (issue #842) (#854)
* fix(chat): stop duplicating interim commentary before tool calls (issue #842)

Interim commentary the agent narrates before each tool call rendered
TWICE. Two mechanisms, both now covered:

1. The old tool.start orphan seal + message.complete's full text: when the
   complete payload repeats the commentary, the final bubble duplicated
   it. onMessageComplete now strips the sealed-orphan prefix (tracked in
   StreamingState) from the final text.

2. On-device capture + DB forensics (2026-08-08) exposed the real
   repeat offender: the app seals the RAW streamed text — which carries
   leading blank lines the model emits before its narration — while the
   backend persists a CLEANED copy (leading whitespace stripped). A
   transcript reload ~10s later merged the REST page, exact-content
   matching failed, and the cleaned REST copy rendered on top of the
   live bubble. sameLogicalMessage now compares trimmed content, so the
   sealed live bubble is covered by the REST row.

Also:
- session.interrupt seals the in-flight streaming message before
  clearing streaming state, so interrupting a commentary turn keeps the
  partial text
- regression tests cover both turn shapes (commentary in the final row →
  stripped; absent → kept) and the whitespace-drift reload case

* fix(chat): match tool rows by gateway call id, drop reasoning-echo dupes (#842)

REST transcript rows carry tool_call_id; live WS bubbles keep tool_id from
tool.start. Match tool rows on that 1:1 identity so MCP/web rows (stored as
raw <untrusted_tool_result> text with no JSON key) dedupe during the
post-turn transcript merge instead of ghosting after the final message.

reasoning.available echo (narration text == message body, sent ~40ms before
each tool.start) no longer overwrites the real reasoning trace, so narration
bubbles don't render their own text twice (body + Reasoning card).

Room v5 migration adds tool_call_id; regression tests use captured
on-device payload shapes.

* fix(chat): flush throttled tokens before orphan seal + prefix-safe dedupe (#842)

The tool.start seal raced the 33ms throttled token flush: deltas landing just
before the transition stayed in the buffer, so the sealed narration orphan came
out truncated (on-device capture: orphan ended '...chickpea', backend persisted
'...chickpea goodness:') and failed the logical-match against the clean REST
row — the commentary ghosted on the next transcript sync.

- ChatStreamingController.flushPendingTokens(): force-flushes the throttled
  TOKEN buffer on MessageStart/MessageComplete/MessageDone/ToolStart so the
  reducer seals the COMPLETE narration.
- sameLogicalMessage: accept prefix-covering (both sides >=40 chars) so a
  residual seal drift can never re-add a substantial narration.
- Regression tests: controller flush + truncated-orphan prefix match.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-08 23:34:58 +03:00
M57andM57 9e741913fc fix(chat): context meter flashes stale window on mid-chat model swap (#817) (#846)
* fix(chat): meter flashes stale window on mid-chat model swap (issue #817)

session.info updates the model label as soon as the backend applies a
swap, but the context meter's denominator kept the old model's window
until the next fetch — the chip briefly rendered the new label over the
old window (e.g. 1M flashing before settling on 262k).

- session.info with a DIFFERENT model now blanks the denominator (chip
  hides while resolving) and immediately re-fires fetchContextUsage()
- fetchContextUsage writes the denominator ONCE, atomically: RPC
  context_max wins, REST model/info is the fallback — a stale pre-swap
  response can never override a fresh one
- regression tests: swap re-fires the fetch and lands the new window;
  an identical session.info leaves the meter untouched

* fix(chat): don't fall back to profile REST window on model swap (issue #817)

After a session-scoped swap the live agent's context_breakdown can
return empty (context_max 0) while the runtime warms up. The refetch
then fell back to GET /api/model/info — PROFILE-scoped — which reports
the profile's main-model window (e.g. 1M), resurrecting the stale
window under the new label. The swap-triggered refetch now trusts only
the RPC's live context_max; the chip stays hidden until the real
window lands.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-07 23:36:27 +03:00
M57andM57 e854559de4 fix(chat): send omit_messages=true on session.resume (issue #842) (#844)
The gateway replays the full transcript over a new socket on
session.resume unless omit_messages is set; mobile hydrates history via
REST in parallel, so the replay is redundant. When a reconnect lands
mid-stream, the replayed message gets a fresh UUID and dedup can't
collapse the 90% orphan with the 100% replay (different ids + content),
leaving a duplicate bubble. Desktop already sends omit_messages: true;
mirror that in both resume paths (ChatViewModel + NotificationReplyReceiver)
and pin it in the exact-map test verifies.

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-08-07 21:36:23 +03:00