From 3bd9716b30395eef7d0ce9cea0218d7ed1f46feb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:34:38 +0000 Subject: [PATCH 01/42] chore: bump coil from 3.6.1 to 3.6.2 Bumps `coil` from 3.6.1 to 3.6.2. Updates `io.coil-kt.coil3:coil-compose` from 3.6.1 to 3.6.2 - [Release notes](https://github.com/coil-kt/coil/releases) - [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md) - [Commits](https://github.com/coil-kt/coil/compare/3.6.1...3.6.2) Updates `io.coil-kt.coil3:coil-gif` from 3.6.1 to 3.6.2 - [Release notes](https://github.com/coil-kt/coil/releases) - [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md) - [Commits](https://github.com/coil-kt/coil/compare/3.6.1...3.6.2) Updates `io.coil-kt.coil3:coil-network-okhttp` from 3.6.1 to 3.6.2 - [Release notes](https://github.com/coil-kt/coil/releases) - [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md) - [Commits](https://github.com/coil-kt/coil/compare/3.6.1...3.6.2) --- updated-dependencies: - dependency-name: io.coil-kt.coil3:coil-compose dependency-version: 3.6.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.coil-kt.coil3:coil-gif dependency-version: 3.6.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.coil-kt.coil3:coil-network-okhttp dependency-version: 3.6.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c5d2f252..157958c4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,7 +25,7 @@ retrofit = "3.0.0" securityCrypto = "1.0.0" mockk = "1.14.11" turbine = "1.2.1" -coil = "3.6.1" +coil = "3.6.2" room = "3.0.2" sqlcipher = "4.18.0" datastore = "1.1.2" From 065dd87d078ff6dd3fbcf9aafa0f2e8f41938f5d Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Thu, 10 Sep 2026 21:46:09 +0300 Subject: [PATCH 02/42] test(tool): pin tool-display engine behavior with characterization suite --- .../chat/tool/ToolViewCharacterizationTest.kt | 778 ++++++++++++++++++ 1 file changed, 778 insertions(+) create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt new file mode 100644 index 00000000..95880999 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt @@ -0,0 +1,778 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Characterization tests for [ToolViewBuilder] — pinned observable behavior + * of the tool-display engine, written against the pre-refactor monolith so + * the renderer/registry split can be proven behavior-preserving. + * + * Every expected value here was produced by the original single-file + * implementation. If one of these needs to change, that is an intentional + * behavior change and must be called out in review — not a refactor detail. + */ +class ToolViewCharacterizationTest { + private fun json(raw: String): JsonElement = Json.parseToJsonElement(raw) + + private fun build( + tool: String, + args: String? = null, + result: String? = null, + isError: Boolean = false, + running: Boolean = false, + ) = ToolViewBuilder.build(tool, args?.let(::json), result?.let(::json), isError, running) + + // ── terminal: streams, exit codes, command line ────────────────────── + + @Test + fun `terminal merged output populates stdout and empty stderr`() { + val view = build("terminal", """{"command":"ls -la"}""", """{"output":"file1\nfile2","exit_code":0}""") + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertEquals("Ran ls -la", view.title) + assertEquals("file1", view.subtitle) + assertEquals("file1\nfile2", view.detail) + assertEquals("file1\nfile2", view.stdout) + assertEquals("", view.stderr) + assertEquals(0, view.exitCode) + assertEquals("ls -la", view.terminalCommand) + } + + @Test + fun `terminal split streams keep stdout and stderr separate`() { + val view = build("terminal", """{"command":"make"}""", """{"stdout":"built","stderr":"warn: x","exit_code":0}""") + + assertEquals("built", view.stdout) + assertEquals("warn: x", view.stderr) + } + + @Test + fun `terminal non-zero exit with only stderr output is not an error`() { + val view = build("terminal", """{"command":"grep foo"}""", """{"exit_code":1,"stderr":"grep: warning"}""") + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertNull(view.error) + assertEquals("", view.stdout) + assertEquals("grep: warning", view.stderr) + assertEquals(1, view.exitCode) + } + + @Test + fun `terminal non-zero exit with no output is an error with generic title`() { + val view = build("terminal", """{"command":"exit 2"}""", """{"exit_code":2}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("Command failed with exit code 2.", view.error) + assertEquals("Terminal", view.title) + assertEquals("Command failed with exit code 2.", view.subtitle) + assertEquals("Command failed with exit code 2.", view.detail) + assertNull(view.countLabel) + } + + @Test + fun `terminal exit code accepts float and numeric string`() { + assertEquals(0, build("terminal", result = """{"output":"ok","exit_code":0.0}""").exitCode) + assertEquals(0, build("terminal", result = """{"output":"ok","exit_code":"0.0"}""").exitCode) + assertEquals(3, build("terminal", result = """{"output":"ok","exit_code":"3"}""").exitCode) + } + + @Test + fun `terminal title collapses plumbing to the main command`() { + val view = + build( + "terminal", + """{"command":"cd /tmp && sleep 70 2>&1 | tail -5; echo \"x_exit=${'$'}{PIPESTATUS[0]}\""}""", + """{"output":"done"}""", + ) + + assertEquals("Ran sleep 70", view.title) + } + + @Test + fun `terminal with no output has empty subtitle and detail`() { + val view = build("terminal", """{"command":"true"}""", """{"exit_code":0,"output":""}""") + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertEquals("", view.subtitle) + assertEquals("", view.detail) + assertNull(view.stdout) + assertNull(view.stderr) + } + + @Test + fun `terminal lines array joins into detail`() { + val view = build("terminal", """{"command":"cat x"}""", """{"lines":["a","b"],"exit_code":0}""") + + assertEquals("a\nb", view.detail) + assertEquals("a", view.subtitle) + } + + @Test + fun `execute_code renders streams but no exit code or terminal command`() { + val view = build("execute_code", """{"code":"print(1)"}""", """{"output":"1","exit_code":0}""") + + assertEquals("Ran code print(1)", view.title) + assertEquals("1", view.stdout) + assertNull(view.exitCode) + assertNull(view.terminalCommand) + } + + @Test + fun `running terminal has running status pending title and no streams`() { + val view = build("terminal", """{"command":"sleep 10"}""", running = true) + + assertEquals(ToolViewStatus.RUNNING, view.status) + assertEquals("Running sleep 10", view.title) + assertNull(view.stdout) + assertNull(view.exitCode) + } + + @Test + fun `null result without running flag is still running status`() { + val view = build("terminal", """{"command":"sleep 10"}""") + + assertEquals(ToolViewStatus.RUNNING, view.status) + // Quirk pinned: title uses the done form because running=false. + assertEquals("Ran sleep 10", view.title) + } + + @Test + fun `terminal error field beats useful output`() { + val view = build("terminal", result = """{"error":"Invalid command: frobnicate"}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("Invalid command: frobnicate", view.error) + } + + // ── status heuristics ──────────────────────────────────────────────── + + @Test + fun `explicit success true wins over isError envelope`() { + val view = build("weird_tool", "{}", """{"success":true,"message":"done"}""", isError = true) + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertNull(view.error) + } + + @Test + fun `isError envelope surfaces extracted message`() { + val view = build("weird_tool", "{}", """{"message":"boom"}""", isError = true) + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("Tool returned an error.", view.error) + } + + @Test + fun `isError with string result uses the string as error`() { + val view = ToolViewBuilder.build("weird_tool", null, JsonPrimitive("everything broke"), isError = true) + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("everything broke", view.error) + } + + @Test + fun `success false without message gets canned error`() { + val view = build("weird_tool", "{}", """{"success":false}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("Tool returned success=false.", view.error) + } + + @Test + fun `ok false with reason surfaces the reason`() { + val view = build("weird_tool", "{}", """{"ok":false,"reason":"quota exceeded"}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("quota exceeded", view.error) + } + + @Test + fun `status failed string is an error`() { + val view = build("weird_tool", "{}", """{"status":"failed"}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("Tool returned status \"failed\".", view.error) + } + + @Test + fun `nested error object surfaces through wrappers`() { + val view = build("weird_tool", "{}", """{"data":{"error":{"message":"Permission denied"}}}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("Permission denied", view.error) + } + + @Test + fun `rejected memory write is a warning not an error`() { + val view = build("memory", """{"action":"add"}""", """{"success":false,"message":"over budget"}""") + + assertEquals(ToolViewStatus.WARNING, view.status) + assertEquals("over budget", view.error) + assertEquals("Memory", view.title) + // Error text and identical detail body are deduplicated. + assertEquals("over budget", view.detail) + } + + // ── file edits: diffs, stats, paths ────────────────────────────────── + + @Test + fun `patch extracts inline diff with stats and empty detail`() { + val view = + build( + "patch", + """{"path":"/repo/src/wiring.tsx"}""", + """{"inline_diff":"--- a/wiring.tsx\n+++ b/wiring.tsx\n@@ -1,2 +1,1 @@\n-old\n-older\n+new"}""", + ) + + assertEquals("wiring.tsx", view.title) + assertEquals("/repo/src/wiring.tsx", view.subtitle) + assertEquals("", view.detail) + assertEquals("/repo/src/wiring.tsx", view.diffPath) + assertEquals(DiffStats(added = 1, removed = 2), view.diffStats) + assertTrue(view.inlineDiff!!.startsWith("--- a/wiring.tsx")) + } + + @Test + fun `inline diff strips ansi codes and review-diff chrome`() { + val view = + build( + "edit_file", + """{"path":"/x/y.kt"}""", + """{"inline_diff":"\u001b[1m┊ review diff\n--- a/y.kt\n+++ b/y.kt\n+\u001b[32madded\u001b[0m"}""", + ) + + assertEquals("--- a/y.kt\n+++ b/y.kt\n+added", view.inlineDiff) + assertEquals(DiffStats(added = 1, removed = 0), view.diffStats) + } + + @Test + fun `diff key works as fallback for inline_diff`() { + val view = build("write_file", """{"path":"/a/b.txt"}""", """{"diff":"+hello"}""") + + assertEquals("+hello", view.inlineDiff) + } + + @Test + fun `file edit without path recovers html path from diff`() { + val view = build("patch", "{}", """{"inline_diff":"--- a/index.html\n+++ b/index.html\n+

hi

"}""") + + assertEquals("index.html", view.title) + assertEquals("index.html", view.diffPath) + } + + @Test + fun `write_file without diff shows message detail`() { + val view = build("write_file", """{"path":"/a/b.txt"}""", """{"message":"Wrote 40 bytes"}""") + + assertEquals("b.txt", view.title) + assertEquals("/a/b.txt", view.subtitle) + assertEquals("Wrote 40 bytes", view.detail) + assertNull(view.inlineDiff) + assertNull(view.diffStats) + } + + @Test + fun `read_file shows content detail and line range title`() { + val view = + build("read_file", """{"path":"/repo/src/a.kt","offset":10,"limit":5}""", """{"content":"10|a\n11|b"}""") + + assertEquals("Read a.kt L10-14", view.title) + assertEquals("/repo/src/a.kt", view.subtitle) + assertEquals("10|a\n11|b", view.detail) + assertNull(view.inlineDiff) + } + + @Test + fun `read_file running title uses reading verb`() { + val view = build("read_file", """{"path":"/repo/src/a.kt"}""", running = true) + + assertEquals("Reading a.kt", view.title) + assertEquals(ToolViewStatus.RUNNING, view.status) + } + + // ── web search ─────────────────────────────────────────────────────── + + @Test + fun `web_search extracts hits count query and detail label`() { + val view = + build( + "web_search", + """{"search_term":"cats"}""", + """{"data":{"web":[ + {"title":"Cat Facts","url":"https://cats.example/1","description":"all about cats"}, + {"title":"More Cats","url":"https://cats.example/2","description":"even more"} + ]}}""", + ) + + assertEquals("Searched \"cats\"", view.title) + assertEquals("Query: cats", view.subtitle) + assertEquals("2 results", view.countLabel) + assertEquals("cats", view.searchQuery) + assertEquals("Search results", view.detailLabel) + assertEquals( + listOf( + SearchHit("Cat Facts", "https://cats.example/1", "all about cats"), + SearchHit("More Cats", "https://cats.example/2", "even more"), + ), + view.searchHits, + ) + } + + @Test + fun `web_search accepts alternate hit field names`() { + val view = + build( + "web_search", + """{"query":"dogs"}""", + """{"results":[{"name":"Dog","href":"https://dogs.example","body":"woof"}]}""", + ) + + assertEquals(listOf(SearchHit("Dog", "https://dogs.example", "woof")), view.searchHits) + assertEquals("dogs", view.searchQuery) + } + + @Test + fun `web_search error suppresses hits and count`() { + val view = build("web_search", """{"search_term":"cats"}""", """{"error":"rate limited"}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertNull(view.searchHits) + assertNull(view.countLabel) + assertEquals("rate limited", view.subtitle) + assertEquals("cats", view.searchQuery) + } + + @Test + fun `web_search running title quotes the query`() { + val view = build("web_search", """{"search_term":"kotlin flow"}""", running = true) + + assertEquals("Searching \"kotlin flow\"", view.title) + } + + // ── web extract / browser tools ────────────────────────────────────── + + @Test + fun `web_extract titles hostname and strips trailing duration from content`() { + val view = + build( + "web_extract", + """{"urls":["https://ex.com/docs"]}""", + """{"content":"Body text in 3.4s","url":"https://ex.com/docs"}""", + ) + + assertEquals("Read ex.com/docs", view.title) + assertEquals("ex.com/docs", view.subtitle) + assertEquals("Body text", view.detail) + } + + @Test + fun `web_extract joins multiple result contents`() { + val view = + build( + "web_extract", + """{"urls":["https://ex.com"]}""", + """{"results":[{"content":"one"},{"content":"two"}]}""", + ) + + // Pinned quirk: the joiner inserts "---" but the divider-line + // stripper in build() removes it again, leaving a blank gap. + assertEquals("one\n\n\ntwo", view.detail) + } + + @Test + fun `browser_navigate done and error titles use hostname`() { + val done = build("browser_navigate", """{"url":"https://example.com/x"}""", """{"ok":true}""") + assertEquals("Opened example.com/x", done.title) + assertEquals("example.com/x", done.subtitle) + + val failed = + build( + "browser_navigate", + """{"url":"https://example.com/x"}""", + """{"error":"net::ERR_CONNECTION_REFUSED","url":"https://example.com/x"}""", + ) + assertEquals(ToolViewStatus.ERROR, failed.status) + assertEquals("Failed to open example.com/x", failed.title) + assertEquals("net::ERR_CONNECTION_REFUSED", failed.subtitle) + } + + @Test + fun `browser_snapshot summarizes controls in subtitle and drops redundant detail`() { + val view = + build( + "browser_snapshot", + "{}", + """{"snapshot":"button \"Save\"\nbutton \"Cancel\"\nlink \"Docs\"\ntextbox \"Name\""}""", + ) + + assertEquals("2 buttons · 1 links · 1 inputs\nTop controls: Save, Cancel, Docs, Name", view.subtitle) + // detailFor returns the same summary; redundancy filter clears it. + assertEquals("", view.detail) + } + + @Test + fun `browser_click internal ref subtitle`() { + val view = build("browser_click", """{"ref":"@e12"}""", """{"ok":true}""") + + assertEquals("Clicked page element (internal ref @e12)", view.subtitle) + } + + @Test + fun `browser_type shows field and value`() { + val view = build("browser_type", """{"label":"Search","text":"kittens"}""", """{"ok":true}""") + + assertEquals("Field: Search · Value: kittens", view.subtitle) + } + + // ── memory / facts ─────────────────────────────────────────────────── + + @Test + fun `memory add title keeps target and subtitle shows message`() { + val view = + build( + "memory", + """{"action":"add","target":"user"}""", + """{"success":true,"message":"Saved to user memory"}""", + ) + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertEquals("Memory Saved (user)", view.title) + assertEquals("Saved to user memory", view.subtitle) + } + + @Test + fun `memory update and remove verbs`() { + assertEquals("Memory Updated", build("memory", """{"action":"update"}""", """{"success":true}""").title) + assertEquals("Memory Removed", build("memory", """{"action":"delete"}""", """{"success":true}""").title) + assertEquals("Saving", build("memory", """{"action":"add"}""", running = true).title) + } + + @Test + fun `fact_store added and listed facts`() { + val added = build("fact_store", """{"action":"add"}""", """{"status":"added","fact_id":42}""") + assertEquals("Fact added (ID: 42)", added.subtitle) + + val listed = + build( + "fact_store", + """{"action":"search","query":"x"}""", + """{"count":1,"results":[{"fact_id":7,"content":"user likes tea",""" + + """"category":"user_pref","trust_score":0.8}]}""", + ) + assertEquals("1 facts (search: x)", listed.subtitle) + assertEquals("#7 user likes tea\n [user_pref] trust: 0.80", listed.detail) + } + + // ── cron / todo / session search / process ─────────────────────────── + + @Test + fun `cronjob list counts jobs and lists schedules`() { + val view = + build( + "cronjob", + """{"action":"list"}""", + """{"jobs":[{"name":"daily","schedule":"0 9 * * *"},{"name":"hourly","schedule":"0 * * * *"}]}""", + ) + + assertEquals("Cron list", view.title) + assertEquals("2 cron jobs", view.subtitle) + assertEquals("- daily · 0 9 * * *\n- hourly · 0 * * * *", view.detail) + } + + @Test + fun `cronjob create shows schedule rows`() { + val view = + build( + "cronjob", + """{"action":"create","name":"reminder"}""", + """{"name":"reminder","schedule":"0 9 * * *","next_run_at":"2026-09-11T09:00"}""", + ) + + assertEquals("Cron create", view.title) + assertEquals("Create reminder", view.subtitle) + assertEquals("Schedule: 0 9 * * *\nNext run: 2026-09-11T09:00", view.detail) + } + + @Test + fun `todo renders summary counts and status markers`() { + val view = + build( + "todo", + """{"action":"update"}""", + """{"summary":{"total":3,"pending":1,"in_progress":1,"completed":1},"todos":[ + {"id":"1","content":"one","status":"pending"}, + {"id":"2","content":"two","status":"in_progress","parent":"1"}, + {"id":"3","content":"three","status":"completed"} + ]}""", + ) + + assertEquals("3 items (1 pending, 1 in_progress, 1 completed)", view.subtitle) + assertEquals("[ ] 1. one\n ↳ [>] 2. two\n[x] 3. three", view.detail) + } + + @Test + fun `session_search modes produce mode-specific subtitles`() { + val discover = + build( + "session_search", + """{"query":"auth"}""", + """{"mode":"discover","count":2,"query":"auth","results":[{"title":"Auth refactor"}]}""", + ) + assertEquals("2 sessions: auth", discover.subtitle) + + val read = + build( + "session_search", + "{}", + """{"mode":"read","message_count":5,"truncated":true,"messages":[]}""", + ) + assertEquals("5 messages (truncated)", read.subtitle) + + val browse = build("session_search", "{}", """{"mode":"browse","count":3}""") + assertEquals("3 recent sessions", browse.subtitle) + } + + @Test + fun `process list renders sessions with status`() { + val view = + build( + "process", + """{"action":"list"}""", + """{"processes":[{"session_id":"p1","status":"running","command":"npm start","running":true}]}""", + ) + + assertEquals("list", view.subtitle) + assertEquals("📌 p1: npm start\n Status: running\n Running: true", view.detail) + } + + // ── delegation / messaging fall back to the generic engine ─────────── + + @Test + fun `delegate_task uses generic title and unwrapped response`() { + val view = + build( + "delegate_task", + """{"goal":"Fix tests"}""", + """{"response":"All done","duration_s":63}""", + ) + + assertEquals("Delegate Task", view.title) + assertEquals("All done", view.subtitle) + assertEquals("", view.detail) + assertEquals("1m 3s", view.durationLabel) + } + + @Test + fun `send_message summarizes fields generically`() { + val view = build("send_message", "{}", """{"status":"sent","channel":"#general"}""") + + assertEquals("Send Message", view.title) + assertEquals("- Status: sent", view.subtitle) + assertEquals("- Status: sent\n- Channel: #general", view.detail) + } + + // ── media tools ────────────────────────────────────────────────────── + + @Test + fun `image_generate subtitle carries url and detail carries prompt`() { + val view = + build( + "image_generate", + """{"prompt":"a cat"}""", + """{"image":"https://img.example/cat.png","modality":"image"}""", + ) + + assertEquals("https://img.example/cat.png (image)", view.subtitle) + assertEquals("🖼️ a cat\n\n🔗 https://img.example/cat.png", view.detail) + // Pinned: imageUrlFor does not read the "image" key, so no thumbnail. + assertNull(view.imageUrl) + } + + @Test + fun `vision_analyze exposes image url and description`() { + val view = + build( + "vision_analyze", + """{"image_url":"data:image/png;base64,AAA"}""", + """{"description":"A cat on a mat"}""", + ) + + assertEquals("data:image/png;base64,AAA", view.imageUrl) + assertEquals("A cat on a mat", view.detail) + assertEquals("data:image/png;base64,AAA", view.subtitle) + } + + @Test + fun `image url only accepted for data or remote image extensions`() { + assertEquals( + "https://a.b/i.jpg?x=1", + build("weird_tool", "{}", """{"url":"https://a.b/i.jpg?x=1"}""").imageUrl, + ) + assertNull(build("weird_tool", "{}", """{"url":"https://a.b/page"}""").imageUrl) + } + + @Test + fun `x_search renders answer with citations and degraded marker`() { + val view = + build( + "x_search", + """{"query":"hermes"}""", + """{"answer":"It is an agent.","degraded":true,"citations":[]}""", + ) + + assertEquals("hermes (no citations)", view.subtitle) + assertEquals("It is an agent.\n\n⚠️ No citations — answer based on model's knowledge", view.detail) + } + + // ── skills / tool discovery / projects / computer use ──────────────── + + @Test + fun `skills_list counts skills and renders entries`() { + val view = + build( + "skills_list", + """{"category":"research"}""", + """{"skills":[{"name":"summarize","description":"Sums up"},{"name":"extract"}]}""", + ) + + assertEquals("2 skills (research)", view.subtitle) + assertEquals("📌 summarize\n Sums up\n\n📌 extract", view.detail) + } + + @Test + fun `skill_view truncates long content at 500 chars`() { + val longContent = "x".repeat(620) + val view = build("skill_view", """{"name":"summarize"}""", """{"content":"$longContent"}""") + + assertEquals("summarize", view.subtitle) + assertEquals("${"x".repeat(500)}\n... [120 more chars]", view.detail) + } + + @Test + fun `tool_search shows match count and entries`() { + val view = + build( + "tool_search", + """{"query":"files"}""", + """{"matches":[{"name":"read_file","description":"Reads"},{"name":"write_file"}]}""", + ) + + assertEquals("files (2 matches)", view.subtitle) + assertEquals("🔧 read_file\n Reads\n\n🔧 write_file", view.detail) + } + + @Test + fun `read_terminal shows line window and terminal block`() { + val view = + build( + "read_terminal", + """{"session_id":"s1"}""", + """{"total_lines":50,"start":1,"end":20,"text":"line one","cursor_row":5}""", + ) + + assertEquals("Lines 1-20 of 50, cursor at row 5", view.subtitle) + assertEquals("━━━ Terminal ━━━ (1:20 / 50) │ cursor row 5\nline one", view.detail) + } + + @Test + fun `computer_use apps list and action ack`() { + val apps = build("computer_use", """{"action":"list_apps"}""", """{"apps":[{"name":"Firefox","pid":42}]}""") + assertEquals("1 apps", apps.subtitle) + assertEquals("1. Firefox (PID: 42)", apps.detail.trim()) + + val action = build("computer_use", """{"action":"click"}""", """{"ok":true}""") + assertEquals("click ✓", action.subtitle) + assertEquals("click: ✅ ok", action.detail) + } + + // ── payload normalization: wrappers, strings, malformed JSON ───────── + + @Test + fun `wrapped payloads unwrap for unknown tools`() { + val view = build("weird_tool", "{}", """{"data":{"message":"All good"}}""") + + assertEquals("Weird Tool", view.title) + assertEquals("All good", view.subtitle) + // Redundant with the subtitle, so cleared. + assertEquals("", view.detail) + } + + @Test + fun `stringified json result is re-parsed`() { + val view = + ToolViewBuilder.build( + "terminal", + json("""{"command":"ls"}"""), + JsonPrimitive("""{"output":"hi","exit_code":0}"""), + ) + + assertEquals("hi", view.stdout) + assertEquals(0, view.exitCode) + } + + @Test + fun `malformed json string result degrades to success with no streams`() { + val view = ToolViewBuilder.build("terminal", json("""{"command":"ls"}"""), JsonPrimitive("{not json")) + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertEquals("Ran ls", view.title) + assertNull(view.stdout) + assertNull(view.exitCode) + } + + @Test + fun `array result summarizes items generically`() { + val view = ToolViewBuilder.build("weird_tool", null, json("""[{"name":"alpha"},{"name":"beta"}]""")) + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertEquals("- alpha\n- beta", view.detail) + } + + // ── counts and duration labels ─────────────────────────────────────── + + @Test + fun `count fields drive count labels with nouns`() { + assertEquals("5 items", build("weird_tool", "{}", """{"count":"5"}""").countLabel) + assertEquals("2 matches", build("weird_tool", "{}", """{"match_count":2}""").countLabel) + assertEquals("3 files", build("weird_tool", "{}", """{"files":["a","b","c"]}""").countLabel) + assertEquals("4 sessions", build("weird_tool", "{}", """{"session_count":4}""").countLabel) + } + + @Test + fun `count from summary text`() { + assertEquals("4 matches", build("weird_tool", "{}", """{"summary":"Found 4 matches in repo"}""").countLabel) + } + + @Test + fun `duration label formats across magnitudes`() { + assertEquals("500ms", build("weird_tool", "{}", """{"duration_s":0.5}""").durationLabel) + assertEquals("9.5s", build("weird_tool", "{}", """{"duration_s":9.5}""").durationLabel) + assertEquals("12s", build("weird_tool", "{}", """{"duration_s":12.3}""").durationLabel) + assertEquals("1m 3s", build("weird_tool", "{}", """{"duration_s":63}""").durationLabel) + assertEquals("1h 1m", build("weird_tool", "{}", """{"duration_s":3660}""").durationLabel) + } + + // ── unknown tool fallback ──────────────────────────────────────────── + + @Test + fun `unknown tool gets humanized title and heuristic summary`() { + val view = build("weird_tool", "{}", """{"result":{"data":{"title":"Build report","completed":true}}}""") + + assertEquals("Weird Tool", view.title) + assertEquals("- Title: Build report", view.subtitle) + assertEquals("- Title: Build report\n- Completed: true", view.detail) + } + + @Test + fun `generic titles strip browser and web prefixes`() { + assertEquals("Fact Store", ToolViewBuilder.genericTitleFor("fact_store")) + assertEquals("Cdp", ToolViewBuilder.genericTitleFor("browser_cdp")) + assertEquals("Tool", ToolViewBuilder.genericTitleFor(null)) + } +} From 91566dfdd70c6b453811080fe91c93721e628342 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Thu, 10 Sep 2026 22:23:41 +0300 Subject: [PATCH 03/42] refactor(tool): split tool display engine --- .../hermescontrol/ui/chat/tool/ToolCall.kt | 34 + .../hermescontrol/ui/chat/tool/ToolCounts.kt | 255 ++ .../hermescontrol/ui/chat/tool/ToolJson.kt | 301 +++ .../ui/chat/tool/ToolRenderer.kt | 54 + .../ui/chat/tool/ToolRendererRegistry.kt | 87 + .../ui/chat/tool/ToolStatusResolver.kt | 105 + .../ui/chat/tool/ToolViewBuilder.kt | 2121 +---------------- .../ui/chat/tool/render/BrowserRenderers.kt | 106 + .../chat/tool/render/ComputerUseRenderer.kt | 156 ++ .../ui/chat/tool/render/FileRenderers.kt | 208 ++ .../ui/chat/tool/render/MediaRenderers.kt | 57 + .../ui/chat/tool/render/MemoryRenderers.kt | 92 + .../ui/chat/tool/render/ProjectRenderers.kt | 68 + .../ui/chat/tool/render/SkillRenderers.kt | 140 ++ .../ui/chat/tool/render/TaskRenderers.kt | 265 ++ .../ui/chat/tool/render/TerminalRenderers.kt | 150 ++ .../ui/chat/tool/render/WebRenderers.kt | 154 ++ 17 files changed, 2301 insertions(+), 2052 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCall.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolStatusResolver.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ComputerUseRenderer.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MemoryRenderers.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ProjectRenderers.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TerminalRenderers.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/WebRenderers.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCall.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCall.kt new file mode 100644 index 00000000..4dfdb350 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCall.kt @@ -0,0 +1,34 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * One normalized tool invocation, as seen by the display engine. + * + * [args] and [result] are the payloads coerced to records (stringified JSON + * re-parsed, non-objects dropped); [rawArgs]/[rawResult] keep the original + * elements for renderers that need arrays, scalars, or wrapper unwrapping. + */ +internal data class ToolCall( + val name: String, + val args: JsonObject?, + val result: JsonObject?, + val rawArgs: JsonElement?, + val rawResult: JsonElement?, +) { + companion object { + fun of( + name: String, + rawArgs: JsonElement?, + rawResult: JsonElement?, + ): ToolCall = + ToolCall( + name = name, + args = ToolJson.parseMaybeObject(rawArgs), + result = ToolJson.parseMaybeObject(rawResult), + rawArgs = rawArgs, + rawResult = rawResult, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt new file mode 100644 index 00000000..fc0fd5be --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt @@ -0,0 +1,255 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * Count-label extraction ("3 results", "2 files") for the collapsed row. + * + * Hunts for a count in this order: known count fields, known list arrays, + * `*_count`/`*_total` field names, then plain-text "Found N matches" + * phrasing in summary/message fields. web_search always counts its hits and + * always reports "results". + */ +internal object ToolCounts { + private val COUNT_FIELD_KEYS = + listOf( + "count", + "total", + "result_count", + "results_count", + "num_results", + "match_count", + "matches_count", + "file_count", + "files_count", + "item_count", + "items_count", + "search_count", + "searches_count", + "source_count", + "sources_count", + "document_count", + "documents_count", + "updated", + "added", + "removed", + "deleted", + "created", + "changed", + "processed", + "steps", + ) + + private val COUNT_ARRAY_KEYS = listOf("results", "items", "matches", "files", "documents", "sources", "rows") + + private val COUNT_EXCLUDED_KEYS = setOf("duration_s", "exit_code", "status_code") + + private val NOUN_BY_FIELD = + mapOf( + "result_count" to "result", + "results_count" to "result", + "num_results" to "result", + "match_count" to "match", + "matches_count" to "match", + "file_count" to "file", + "files_count" to "file", + "item_count" to "item", + "items_count" to "item", + "search_count" to "search", + "searches_count" to "search", + "source_count" to "source", + "sources_count" to "source", + "document_count" to "document", + "documents_count" to "document", + "updated" to "item", + "added" to "item", + "removed" to "item", + "deleted" to "item", + "created" to "item", + "changed" to "item", + "processed" to "item", + "steps" to "step", + ) + + private val NOUN_BY_ARRAY = + mapOf( + "documents" to "document", + "files" to "file", + "items" to "item", + "matches" to "match", + "results" to "result", + "rows" to "row", + "sources" to "source", + ) + + private fun countFromUnknown(value: JsonElement?): Int? { + if (value is JsonArray) { + return if (value.isNotEmpty()) value.size else null + } + + val n = ToolJson.numberValue(value) ?: return null + + if (n <= 0) { + return null + } + + return Math.round(n).toInt() + } + + private fun singularizeNoun(noun: String): String { + val normalized = noun.lowercase() + + if (normalized.isEmpty()) { + return "" + } + + if (normalized.endsWith("ies") && normalized.length > 3) { + return "${normalized.dropLast(3)}y" + } + + if (Regex("(xes|zes|ches|shes|sses)$").containsMatchIn(normalized) && normalized.length > 3) { + return normalized.dropLast(2) + } + + if (normalized.endsWith("s") && normalized.length > 2 && !normalized.endsWith("ss")) { + return normalized.dropLast(1) + } + + return normalized + } + + fun pluralizeNoun( + noun: String, + count: Int, + ): String { + if (count == 1) { + return noun + } + + if (noun == "search") { + return "searches" + } + + if (noun.endsWith( + "y", + ) && noun.length > 1 && !Regex("[aeiou]y$", RegexOption.IGNORE_CASE).containsMatchIn(noun) + ) { + return "${noun.dropLast(1)}ies" + } + + if (Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE).containsMatchIn(noun)) { + return "${noun}es" + } + + return "${noun}s" + } + + private fun countMetric( + count: Int, + noun: String, + ): Pair = count to singularizeNoun(noun).ifEmpty { "item" } + + private fun countFromRecord( + record: JsonObject, + fallbackNoun: String, + ): Pair? { + for (key in COUNT_FIELD_KEYS) { + val count = countFromUnknown(record[key]) ?: continue + + return countMetric(count, NOUN_BY_FIELD[key] ?: fallbackNoun) + } + + for (key in COUNT_ARRAY_KEYS) { + val count = countFromUnknown(record[key]) ?: continue + + return countMetric(count, NOUN_BY_ARRAY[key] ?: fallbackNoun) + } + + for ((key, value) in record) { + if (key in COUNT_EXCLUDED_KEYS) { + continue + } + if (!Regex("_count$|_total$").containsMatchIn(key)) { + continue + } + + val count = countFromUnknown(value) ?: continue + val stripped = key.lowercase().replace(Regex("_(count|total)$"), "").replace(Regex("^num_"), "") + + return countMetric(count, singularizeNoun(stripped).ifEmpty { fallbackNoun }) + } + + return null + } + + private fun countFromText( + text: String, + fallbackNoun: String, + ): Pair? { + val t = text.trim() + if (t.isEmpty()) { + return null + } + + val unitMatch = + Regex( + """\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""", + RegexOption.IGNORE_CASE, + ).find(t) + ?: Regex( + """\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""", + RegexOption.IGNORE_CASE, + ).find(t) + + val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null + val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun + + return if (n > 0) countMetric(n, noun) else null + } + + fun countLabelFor(call: ToolCall): Pair? { + val rawResult = call.rawResult ?: return null + val toolName = call.name + val result = call.result + + val fallbackNoun = + when (toolName) { + "browser_snapshot", "todo" -> "item" + "list_files" -> "file" + "search_files", "session_search_recall", "web_search" -> "result" + "skills_list" -> "skill" + else -> "item" + } + + if (toolName == "web_search") { + val hits = ToolJson.collectResultItems(rawResult) + + if (hits.isNotEmpty()) { + return countMetric(hits.size, "result") + } + } + + val direct = countFromRecord(result ?: JsonObject(emptyMap()), fallbackNoun) + if (direct != null) { + return if (toolName == "web_search") countMetric(direct.first, "result") else direct + } + + val payload = ToolJson.unwrapToolPayload(rawResult) + if (payload !== rawResult && payload is JsonObject) { + val payloadCount = countFromRecord(payload, fallbackNoun) + + if (payloadCount != null) { + return if (toolName == "web_search") countMetric(payloadCount.first, "result") else payloadCount + } + } + + val summaryText = + ToolJson + .firstString(result, listOf("summary", "message", "detail")) + .ifEmpty { ToolJson.fallbackDetailText(call.args, rawResult) } + + return countFromText(summaryText, fallbackNoun) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt new file mode 100644 index 00000000..e2bd3347 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt @@ -0,0 +1,301 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Shared parsing/normalization primitives for the tool-display engine. + * + * Everything in here is payload-shape plumbing — no per-tool knowledge. + * Per-tool formatting lives in the renderers under [render][com.m57.hermescontrol.ui.chat.tool.render]. + */ +internal object ToolJson { + private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE) + + /** First non-blank string value among [keys], trimmed. */ + fun firstString( + record: JsonObject?, + keys: List, + ): String { + val r = record ?: return "" + + for (k in keys) { + val v = r[k] ?: continue + if (v is JsonPrimitive && v.isString && v.content.trim().isNotEmpty()) { + return v.content.trim() + } + } + + return "" + } + + fun numberValue(value: JsonElement?): Double? { + val v = value ?: return null + if (v is JsonNull) { + return null + } + if (v !is JsonPrimitive) { + return null + } + + // TS source does `Number(value)` — numeric STRINGS ("0.0", "12") are + // accepted, matching the desktop engine and the gateway's habit of + // shipping exit_code as a string or float. + return v.content.toDoubleOrNull() + } + + fun intValue(value: JsonElement?): Int? { + val n = numberValue(value) ?: return null + + return n.toInt() + } + + /** True only for a literal JSON `true` (not the string "true"). */ + fun boolTrue(value: JsonElement?): Boolean = + (value as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true + + /** True only for a literal JSON `false` (not the string "false"). */ + fun boolFalse(value: JsonElement?): Boolean = + (value as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true + + fun compactPreview( + value: String, + max: Int = 72, + ): String { + val line = value.replace(Regex("\\s+"), " ").trim() + + return if (line.length > max) "${line.take(max - 1)}…" else line + } + + fun compactPreview( + value: JsonElement?, + max: Int = 72, + ): String { + val raw: String = + if (value is JsonPrimitive && value.isString) { + value.content + } else { + firstString(parseMaybeObject(value), listOf("context")) + } + + return compactPreview(raw, max) + } + + fun contextValue(value: JsonElement?): String { + val row = parseMaybeObject(value) + val context = firstString(row, listOf("context")) + val preview = firstString(row, listOf("preview")) + + if (context.isNotEmpty()) { + return context + } + if (preview.isNotEmpty()) { + return preview + } + + return (value as? JsonPrimitive)?.takeIf { it.isString }?.content ?: "" + } + + /** Objects pass through; stringified JSON objects are re-parsed. */ + fun parseMaybeObject(value: JsonElement?): JsonObject? = + when (value) { + is JsonObject -> { + value + } + + is JsonPrimitive -> { + if (!value.isString || value.content.trim().isEmpty()) { + null + } else { + try { + Json.parseToJsonElement(value.content) as? JsonObject + } catch (_: Exception) { + null + } + } + } + + else -> { + null + } + } + + /** Peel one `data`/`result`/`output`/`response`/`payload` wrapper layer. */ + fun unwrapToolPayload(value: JsonElement?): JsonElement? { + val record = parseMaybeObject(value) ?: return value + + for (key in listOf("data", "result", "output", "response", "payload")) { + val payload = record[key] ?: continue + if (payload is JsonNull) { + continue + } + + return payload + } + + return value + } + + fun findFirstUrl(vararg sources: JsonElement?): String { + for (src in sources) { + if (src is JsonPrimitive && src.isString) { + val m = URL_PATTERN.find(src.content) + + if (m != null) { + return m.value + } + } else if (src is JsonObject) { + for (v in src.values) { + val found = findFirstUrl(v) + + if (found.isNotEmpty()) { + return found + } + } + } + } + + return "" + } + + fun hostnameOf(value: String): String = + try { + val url = java.net.URI(value) + val path = url.path?.takeIf { it.isNotEmpty() && it != "/" } ?: "" + "${url.host ?: value}$path" + } catch (_: Exception) { + value + } + + fun stripAnsi(value: String): String = value.replace(Regex("\u001B\\[[0-9;]*m"), "") + + fun stripDividerLines(value: String): String = + value + .split("\n") + .filter { !Regex("^[-=]{3,}\\s*$").matches(it.trim()) } + .joinToString("\n") + .trim() + + fun cleanVisibleText(text: String): String = + text + .replace(Regex("`{3,}"), "") + .replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "") + .replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" } + + /** Find the result-item array behind common list keys or wrappers. */ + fun collectResultItems(value: JsonElement?): List { + if (value is JsonArray) { + return value.toList() + } + + val record = parseMaybeObject(value) ?: return emptyList() + + val keys = + listOf( + "web", + "results", + "search_results", + "sources", + "web_sources", + "items", + "organic_results", + "organic", + "matches", + "documents", + ) + + for (key in keys) { + val candidate = record[key] ?: continue + + if (candidate is JsonArray) { + return candidate.toList() + } + + if (candidate is JsonObject) { + val nested = collectResultItems(candidate) + + if (nested.isNotEmpty()) { + return nested + } + } + } + + val payload = unwrapToolPayload(record) + + return if (payload === record) emptyList() else collectResultItems(payload) + } + + /** Humanized generic title: "fact_store" → "Fact Store", sans browser_/web_ prefix. */ + fun titleForTool(name: String): String { + val normalized = name.replace(Regex("^browser_"), "").replace(Regex("^web_"), "") + + return normalized + .split("_") + .filter { it.isNotEmpty() } + .joinToString( + " ", + ) { part -> part.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } + .ifEmpty { name } + } + + /** Generic detail: prefer a fresh result context, then args, then the heuristic summary. */ + fun fallbackDetailText( + args: JsonElement?, + result: JsonElement?, + ): String { + val argContext = contextValue(args) + val resultContext = contextValue(result) + + if (resultContext.isNotEmpty() && resultContext != argContext) { + return resultContext + } + + if (argContext.isNotEmpty()) { + return argContext + } + + if (result != null) { + return ToolResultSummary.formatToolResultSummary(result) + } + + return ToolResultSummary.formatToolResultSummary(args) + } + + fun formatDurationSeconds(seconds: Double): String { + if (!seconds.isFinite() || seconds < 0) { + return "" + } + + if (seconds < 1) { + val ms = maxOf(1, Math.round(seconds * 1000)) + return "${ms}ms" + } + + if (seconds < 60) { + return if (seconds >= 10) "${seconds.toInt()}s" else "${"%.1f".format(seconds)}s" + } + + val wholeSeconds = Math.round(seconds) + val minutes = wholeSeconds / 60 + val remSeconds = wholeSeconds % 60 + + if (minutes < 60) { + return if (remSeconds != 0L) "${minutes}m ${remSeconds}s" else "${minutes}m" + } + + val hours = minutes / 60 + val remMinutes = minutes % 60 + + return if (remMinutes != 0L) "${hours}h ${remMinutes}m" else "${hours}h" + } + + fun durationLabel(result: JsonObject?): String? { + val seconds = numberValue(result?.get("duration_s")) ?: return null + + return formatDurationSeconds(seconds) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt new file mode 100644 index 00000000..ebc7b4c1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt @@ -0,0 +1,54 @@ +package com.m57.hermescontrol.ui.chat.tool + +/** + * Per-tool (or per-family) display strategy. + * + * Every hook is a *refinement*: return null to fall back to the generic + * treatment ([ToolViewBuilder] humanizes the tool name for titles and uses + * [ToolResultSummary]-based text for subtitle/detail). Returning an empty + * string is a real answer — "this tool intentionally shows nothing here" — + * and suppresses the generic fallback. + */ +internal interface ToolRenderer { + /** Title while the call is still running ("Running ls", "Searching …"). */ + fun pendingTitle(call: ToolCall): String? = null + + /** Title after successful completion ("Ran ls", "Opened example.com"). */ + fun doneTitle(call: ToolCall): String? = null + + /** Title when the call errored. */ + fun errorTitle(call: ToolCall): String? = null + + /** Secondary line naming what was acted on. Not used when an error is shown. */ + fun subtitle(call: ToolCall): String? = null + + /** Expanded body text. */ + fun detail(call: ToolCall): String? = null + + /** Structured extras (streams, diffs, search hits) for special row layouts. */ + fun extras( + call: ToolCall, + status: ToolViewStatus, + ): ToolViewExtras = ToolViewExtras.NONE +} + +/** + * Structured, tool-specific fields a renderer can contribute to [ToolView] + * beyond the title/subtitle/detail text. + */ +internal data class ToolViewExtras( + val detailLabel: String? = null, + val stdout: String? = null, + val stderr: String? = null, + val exitCode: Int? = null, + val terminalCommand: String? = null, + val inlineDiff: String? = null, + val diffPath: String? = null, + val diffStats: DiffStats? = null, + val searchHits: List? = null, + val searchQuery: String? = null, +) { + companion object { + val NONE = ToolViewExtras() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt new file mode 100644 index 00000000..dfacec8f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt @@ -0,0 +1,87 @@ +package com.m57.hermescontrol.ui.chat.tool + +import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer +import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer +import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer + +/** + * Maps tool names to their display strategy. + * + * Unknown tools get [GenericToolRenderer]: every hook returns null, so + * [ToolViewBuilder] falls back to the humanized tool name and the + * [ToolResultSummary] heuristics — never a raw JSON dump. + */ +internal object ToolRendererRegistry { + /** All-default renderer: defers everything to the generic fallbacks. */ + private object GenericToolRenderer : ToolRenderer + + private val renderers: Map = + mapOf( + // Shell + "terminal" to TerminalRenderer, + "execute_code" to TerminalRenderer, + "read_terminal" to ReadTerminalRenderer, + // Files + "read_file" to ReadFileRenderer, + "edit_file" to FileEditRenderer, + "patch" to FileEditRenderer, + "write_file" to FileEditRenderer, + // Web + "web_search" to WebSearchRenderer, + "web_extract" to WebExtractRenderer, + "x_search" to XSearchRenderer, + // Browser + "browser_navigate" to BrowserNavigateRenderer, + "browser_snapshot" to BrowserSnapshotRenderer, + "browser_click" to BrowserClickRenderer, + "browser_fill" to BrowserTypeRenderer, + "browser_type" to BrowserTypeRenderer, + // Memory / facts + "memory" to MemoryRenderer, + "fact_store" to FactStoreRenderer, + // Task management + "cronjob" to CronjobRenderer, + "todo" to TodoRenderer, + "session_search" to SessionSearchRenderer, + "process" to ProcessRenderer, + // Skills / tool discovery + "skills_list" to SkillsListRenderer, + "skill_view" to SkillViewRenderer, + "skill_manage" to SkillManageRenderer, + "tool_search" to ToolSearchRenderer, + // Media + "image_generate" to ImageGenerateRenderer, + "vision_analyze" to VisionAnalyzeRenderer, + // Projects + "project_list" to ProjectListRenderer, + "project_create" to ProjectMutateRenderer, + "project_switch" to ProjectMutateRenderer, + // Desktop automation + "computer_use" to ComputerUseRenderer, + ) + + fun rendererFor(toolName: String): ToolRenderer = renderers[toolName] ?: GenericToolRenderer +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolStatusResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolStatusResolver.kt new file mode 100644 index 00000000..adb705d1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolStatusResolver.kt @@ -0,0 +1,105 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.JsonPrimitive + +/** + * Status + error-text heuristics for a completed tool call, ported from the + * desktop engine. Key rules, in order: + * + * - running (or a missing result) is RUNNING + * - an explicit `success: true` / `ok: true` beats every error heuristic, + * including a stale isError envelope + * - a non-zero exit code alone is NOT an error: grep returns 1 on no-match, + * diff returns 1 on differences, piped commands surface the last stage's + * code — all routinely produce useful output. It only becomes an error + * when the command produced no real output to show. + * - a rejected memory write is a budget negotiation → WARNING, not ERROR + */ +internal object ToolStatusResolver { + fun errorText( + call: ToolCall, + isError: Boolean, + ): String { + val result = call.rawResult + val resultRecord = call.result + + if (isError) { + val extracted = ToolResultSummary.extractToolErrorMessage(result) + if (extracted.isNotEmpty()) { + return extracted + } + if (result is JsonPrimitive && result.isString && result.content.trim().isNotEmpty()) { + return result.content.trim() + } + + return "Tool returned an error." + } + + val error = ToolJson.firstString(resultRecord, listOf("error")) + if (error.isNotEmpty()) { + return error + } + + val extracted = ToolResultSummary.extractToolErrorMessage(result) + if (extracted.isNotEmpty()) { + return extracted + } + + if (resultRecord != null) { + if (ToolJson.boolFalse(resultRecord["success"]) || ToolJson.boolFalse(resultRecord["ok"])) { + return ToolJson + .firstString( + resultRecord, + listOf("message", "reason", "detail"), + ).ifEmpty { "Tool returned success=false." } + } + + val status = ToolJson.firstString(resultRecord, listOf("status")) + if (Regex("\\b(error|failed|failure)\\b", RegexOption.IGNORE_CASE).containsMatchIn(status)) { + return ToolJson + .firstString( + resultRecord, + listOf("message", "reason", "detail"), + ).ifEmpty { "Tool returned status \"$status\"." } + } + + val exit = ToolJson.numberValue(resultRecord["exit_code"]) + if (exit != null && exit != 0.0) { + val hasOutput = + listOf("output", "stdout", "stderr", "output_preview") + .any { k -> ToolJson.firstString(resultRecord, listOf(k)).isNotEmpty() } + + if (!hasOutput) { + return "Command failed with exit code ${exit.toInt()}." + } + } + } + + return "" + } + + fun status( + call: ToolCall, + isError: Boolean, + running: Boolean, + ): ToolViewStatus { + if (running || call.rawResult == null) { + return ToolViewStatus.RUNNING + } + + // Explicit success wins over isError / nested-error heuristics. + val resultRecord = call.result + if (resultRecord != null && + (ToolJson.boolTrue(resultRecord["success"]) || ToolJson.boolTrue(resultRecord["ok"])) + ) { + return ToolViewStatus.SUCCESS + } + + if (errorText(call, isError).isEmpty()) { + return ToolViewStatus.SUCCESS + } + + // A rejected memory write is a budget negotiation, not a failure. + return if (call.name == "memory") ToolViewStatus.WARNING else ToolViewStatus.ERROR + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index 2ce9af42..3eb334b0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -1,821 +1,34 @@ package com.m57.hermescontrol.ui.chat.tool -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive /** - * Per-tool display engine — a port of the desktop app's `buildToolView` - * (fallback-model), extended with mobile-specific tool cases. + * The tool-display engine's orchestrator — a port of the desktop app's + * `buildToolView` (fallback-model), extended with mobile-specific tools. * * One pure function turns a tool call's args/result into a [ToolView] the - * renderer can paint without touching raw JSON. Structure: + * renderer can paint without touching raw JSON. The pipeline: * - * - status heuristics (running / success / error / warning) with the - * desktop's error-detection rules (non-zero exit alone is NOT an error) - * - per-tool title/subtitle/detail optimizations (terminal, file edits, - * web/browser tools, memory, cron, …) - * - count-label extraction, search-hit parsing, stdout/stderr split, - * inline diff extraction, duration formatting - * - generic fallback via [ToolResultSummary] so unknown tools still read - * as a human summary instead of a raw JSON dump + * 1. normalize the payload into a [ToolCall] (stringified JSON re-parsed) + * 2. resolve status via [ToolStatusResolver] (running / success / error / + * warning, with the desktop's rule that a non-zero exit code alone is + * NOT an error) + * 3. dispatch to the tool's [ToolRenderer] (via [ToolRendererRegistry]) for + * title/subtitle/detail and structured extras (streams, diffs, hits) + * 4. apply the cross-cutting polish: error/detail merging, redundancy + * suppression, count labels ([ToolCounts]), duration, image URLs + * + * Unknown tools fall back to [ToolResultSummary] heuristics so they still + * read as a human summary instead of a raw JSON dump. */ object ToolViewBuilder { - private val HTML_PATH_REGEX = - Regex( - "(?:^|\\s)(?:[ab]/)?([^\\s]+\\.html?)(?=\\s|$)", - RegexOption.IGNORE_CASE, - ) - - // ── shared field helpers ───────────────────────────────────────────── - - private fun firstString( - record: JsonObject?, - keys: List, - ): String { - val r = record ?: return "" - - for (k in keys) { - val v = r[k] ?: continue - if (v is JsonPrimitive && v.isString && v.content.trim().isNotEmpty()) { - return v.content.trim() - } - } - - return "" - } - - private fun numberValue(value: JsonElement?): Double? { - val v = value ?: return null - if (v is JsonNull) { - return null - } - if (v !is JsonPrimitive) { - return null - } - - // TS source does `Number(value)` — numeric STRINGS ("0.0", "12") are - // accepted, matching the desktop engine and the gateway's habit of - // shipping exit_code as a string or float. - return v.content.toDoubleOrNull() - } - - private fun intValue(value: JsonElement?): Int? { - val n = numberValue(value) ?: return null - - return n.toInt() - } - - private fun compactPreview( - value: String, - max: Int = 72, - ): String { - val line = value.replace(Regex("\\s+"), " ").trim() - - return if (line.length > max) "${line.take(max - 1)}…" else line - } - - private fun compactPreview( - value: JsonElement?, - max: Int = 72, - ): String { - var raw: String? = null - - if (value is JsonPrimitive && value.isString) { - raw = value.content - } else { - raw = firstString(parseMaybeObject(value), listOf("context")) - } - - return compactPreview(raw, max) - } - - private fun contextValue(value: JsonElement?): String { - val row = parseMaybeObject(value) - val context = firstString(row, listOf("context")) - val preview = firstString(row, listOf("preview")) - - if (context.isNotEmpty()) { - return context - } - if (preview.isNotEmpty()) { - return preview - } - - return (value as? JsonPrimitive)?.takeIf { it.isString }?.content ?: "" - } - - private fun parseMaybeObject(value: JsonElement?): JsonObject? = - when (value) { - is JsonObject -> { - value - } - - is JsonPrimitive -> { - if (!value.isString || value.content.trim().isEmpty()) { - null - } else { - try { - Json.parseToJsonElement(value.content) as? JsonObject - } catch (_: Exception) { - null - } - } - } - - else -> { - null - } - } - - private fun unwrapToolPayload(value: JsonElement?): JsonElement? { - val record = parseMaybeObject(value) ?: return value - - for (key in listOf("data", "result", "output", "response", "payload")) { - val payload = record[key] ?: continue - if (payload is JsonNull) { - continue - } - - return payload - } - - return value - } - - private fun looksLikeUrl(value: String): Boolean = - Regex("^https?://", RegexOption.IGNORE_CASE).containsMatchIn(value) - - private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE) - - private fun findFirstUrl(vararg sources: JsonElement?): String { - for (src in sources) { - if (src is JsonPrimitive && src.isString) { - val m = URL_PATTERN.find(src.content) - - if (m != null) { - return m.value - } - } else if (src is JsonObject) { - for (v in src.values) { - val found = findFirstUrl(v) - - if (found.isNotEmpty()) { - return found - } - } - } - } - - return "" - } - - private fun hostnameOf(value: String): String = - try { - val url = java.net.URI(value) - val path = url.path?.takeIf { it.isNotEmpty() && it != "/" } ?: "" - "${url.host ?: value}$path" - } catch (_: Exception) { - value - } - - private fun formatDurationSeconds(seconds: Double): String { - if (!seconds.isFinite() || seconds < 0) { - return "" - } - - if (seconds < 1) { - val ms = maxOf(1, Math.round(seconds * 1000)) - return "${ms}ms" - } - - if (seconds < 60) { - return if (seconds >= 10) "${seconds.toInt()}s" else "${"%.1f".format(seconds)}s" - } - - val wholeSeconds = Math.round(seconds) - val minutes = wholeSeconds / 60 - val remSeconds = wholeSeconds % 60 - - if (minutes < 60) { - return if (remSeconds != 0L) "${minutes}m ${remSeconds}s" else "${minutes}m" - } - - val hours = minutes / 60 - val remMinutes = minutes % 60 - - return if (remMinutes != 0L) "${hours}h ${remMinutes}m" else "${hours}h" - } - - private fun durationLabel(result: JsonObject?): String? { - val seconds = numberValue(result?.get("duration_s")) ?: return null - - return formatDurationSeconds(seconds) - } - - // ── file edit helpers ──────────────────────────────────────────────── - - private val FILE_EDIT_TOOL_NAMES = setOf("edit_file", "patch", "write_file") - - private fun isFileEditTool(toolName: String): Boolean = toolName in FILE_EDIT_TOOL_NAMES - - private fun fileEditBasename(path: String): String { - val normalized = path.replace('\\', '/').trim() - - return normalized.split("/").filter { it.isNotEmpty() }.lastOrNull() ?: normalized - } - - private fun fileEditPath( - args: JsonObject?, - result: JsonObject?, - ): String { - val fromArgs = firstString(args, listOf("path", "file", "filepath")) - if (fromArgs.isNotEmpty()) { - return fromArgs - } - - val fromResult = firstString(result, listOf("path", "file", "filepath", "resolved_path")) - if (fromResult.isNotEmpty()) { - return fromResult - } - - return htmlPathFromInlineDiff(firstString(result, listOf("inline_diff", "diff"))) - } - - private fun stripAnsi(value: String): String = value.replace(Regex("\u001B\\[[0-9;]*m"), "") - - private fun stripInlineDiffChrome(value: String): String = - stripAnsi(value) - .replace(Regex("^\\s*┊\\s*review diff\\s*\\n", RegexOption.IGNORE_CASE), "") - .trim() - - private fun inlineDiffFromResult(result: JsonElement?): String { - val record = parseMaybeObject(result) ?: return "" - - for (key in listOf("inline_diff", "diff")) { - val value = record[key] - - if (value is JsonPrimitive && value.isString && value.content.trim().isNotEmpty()) { - return stripInlineDiffChrome(value.content) - } - } - - return "" - } - - private fun htmlPathFromInlineDiff(value: String): String { - val cleaned = stripInlineDiffChrome(value) - - for (match in HTML_PATH_REGEX.findAll(cleaned)) { - val candidate = match.groupValues[1].trim() - - if (candidate.isNotEmpty()) { - return candidate - } - } - - return "" - } - - private fun countDiffLineStats(diff: String): DiffStats { - var added = 0 - var removed = 0 - - for (line in diff.split("\n")) { - when { - line.startsWith("+") && !line.startsWith("+++") -> added += 1 - line.startsWith("-") && !line.startsWith("---") -> removed += 1 - } - } - - return DiffStats(added = added, removed = removed) - } - - // ── status + error detection ───────────────────────────────────────── - - private fun toolErrorText( - toolName: String, - isError: Boolean, - result: JsonElement?, - resultRecord: JsonObject?, - ): String { - if (isError) { - val extracted = ToolResultSummary.extractToolErrorMessage(result) - if (extracted.isNotEmpty()) { - return extracted - } - if (result is JsonPrimitive && result.isString && result.content.trim().isNotEmpty()) { - return result.content.trim() - } - - return "Tool returned an error." - } - - val error = firstString(resultRecord, listOf("error")) - if (error.isNotEmpty()) { - return error - } - - val extracted = ToolResultSummary.extractToolErrorMessage(result) - if (extracted.isNotEmpty()) { - return extracted - } - - if (resultRecord != null) { - val successFalse = - (resultRecord["success"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } - val okFalse = (resultRecord["ok"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } - - if (successFalse == true || okFalse == true) { - return firstString( - resultRecord, - listOf("message", "reason", "detail"), - ).ifEmpty { "Tool returned success=false." } - } - - val status = firstString(resultRecord, listOf("status")) - if (Regex("\\b(error|failed|failure)\\b", RegexOption.IGNORE_CASE).containsMatchIn(status)) { - return firstString( - resultRecord, - listOf("message", "reason", "detail"), - ).ifEmpty { "Tool returned status \"$status\"." } - } - - // A non-zero exit code alone is a weak failure signal: grep - // returns 1 on no-match, diff returns 1 on differences, piped - // commands surface the last stage's code — all routinely produce - // useful output. Only treat it as an error when the command - // produced no real output to show. - val exit = numberValue(resultRecord["exit_code"]) - if (exit != null && exit != 0.0) { - val hasOutput = - listOf("output", "stdout", "stderr", "output_preview") - .any { k -> firstString(resultRecord, listOf(k)).isNotEmpty() } - - if (!hasOutput) { - return "Command failed with exit code ${exit.toInt()}." - } - } - } - - return "" - } - - private fun toolStatus( - toolName: String, - result: JsonElement?, - resultRecord: JsonObject?, - isError: Boolean, - running: Boolean, - ): ToolViewStatus { - if (running || result == null) { - return ToolViewStatus.RUNNING - } - - // Explicit success wins over isError / nested-error heuristics. - if (resultRecord != null) { - val successTrue = (resultRecord["success"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } - val okTrue = (resultRecord["ok"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } - - if (successTrue == true || okTrue == true) { - return ToolViewStatus.SUCCESS - } - } - - if (toolErrorText(toolName, isError, result, resultRecord).isEmpty()) { - return ToolViewStatus.SUCCESS - } - - // A rejected memory write is a budget negotiation, not a failure. - return if (toolName == "memory") ToolViewStatus.WARNING else ToolViewStatus.ERROR - } - - // ── titles ─────────────────────────────────────────────────────────── - /** * The generic humanized title for a tool name ("fact_store" -> "Fact * Store"). Exposed so the view can drop the title from the collapsed * summary when it adds nothing over the header row's own tool name. */ - internal fun genericTitleFor(toolName: String?): String = titleForTool(toolName ?: "tool") - - private fun titleForTool(name: String): String { - val normalized = name.replace(Regex("^browser_"), "").replace(Regex("^web_"), "") - - return normalized - .split("_") - .filter { it.isNotEmpty() } - .joinToString( - " ", - ) { part -> part.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } - .ifEmpty { name } - } - - private fun shellCommand(args: JsonObject?): String = - firstString(args, listOf("command", "code")) - .ifEmpty { firstString(args, listOf("context", "preview")) } - .ifEmpty { contextValue(args) } - - private fun summarizeBrowserSnapshot(snapshot: String): String { - fun count(re: Regex): Int = re.findAll(snapshot).count() - - val stats = - listOf( - "${count(Regex("""button\s+"[^"]+"""))} buttons", - "${count(Regex("""link\s+"[^"]+"""))} links", - "${count(Regex("""(?:textbox|combobox|searchbox)\s+"[^"]+"""))} inputs", - ).joinToString(" · ") - - val labels = - Regex("""(?:button|link|combobox|textbox)\s+"([^"]+)"""") - .findAll(snapshot) - .map { it.groupValues[1].trim() } - .filter { it.isNotEmpty() } - .take(4) - .toList() - - return if (labels.isNotEmpty()) "$stats\nTop controls: ${labels.joinToString(", ")}" else stats - } - - private fun readFileDisplayTarget( - args: JsonObject?, - result: JsonObject?, - ): String { - val inherited = firstString(args, listOf("context", "preview")) - if (inherited.isNotEmpty()) { - return inherited - } - - val path = firstString(args, listOf("path", "file", "filepath")) - if (path.isEmpty()) { - return "" - } - - val offset = intValue(args?.get("offset")) - val limit = intValue(args?.get("limit")) - var lineLabel = "" - - if (offset != null) { - lineLabel = if (limit == null || limit <= 1) "L$offset" else "L$offset-${offset + limit - 1}" - } else if (limit != null) { - val content = firstString(result, listOf("content")) - val lines = - content - .split("\n") - .mapNotNull { line -> - Regex("^(\\d+)\\|") - .find(line) - ?.groupValues - ?.get(1) - ?.toIntOrNull() - } - - if (lines.isNotEmpty()) { - lineLabel = - if (lines.first() == lines.last()) { - "L${lines.first()}" - } else { - "L${lines.first()}-${lines.last()}" - } - } - } - - return listOf(fileEditBasename(path), lineLabel).filter { it.isNotEmpty() }.joinToString(" ") - } - - // ── count extraction ───────────────────────────────────────────────── - - private val COUNT_FIELD_KEYS = - listOf( - "count", - "total", - "result_count", - "results_count", - "num_results", - "match_count", - "matches_count", - "file_count", - "files_count", - "item_count", - "items_count", - "search_count", - "searches_count", - "source_count", - "sources_count", - "document_count", - "documents_count", - "updated", - "added", - "removed", - "deleted", - "created", - "changed", - "processed", - "steps", - ) - - private val COUNT_ARRAY_KEYS = listOf("results", "items", "matches", "files", "documents", "sources", "rows") - - private val COUNT_EXCLUDED_KEYS = setOf("duration_s", "exit_code", "status_code") - - private fun countFromUnknown(value: JsonElement?): Int? { - if (value is JsonArray) { - return if (value.isNotEmpty()) value.size else null - } - - val n = numberValue(value) ?: return null - - if (n <= 0) { - return null - } - - return Math.round(n).toInt() - } - - private fun singularizeNoun(noun: String): String { - val normalized = noun.lowercase() - - if (normalized.isEmpty()) { - return "" - } - - if (normalized.endsWith("ies") && normalized.length > 3) { - return "${normalized.dropLast(3)}y" - } - - if (Regex("(xes|zes|ches|shes|sses)$").containsMatchIn(normalized) && normalized.length > 3) { - return normalized.dropLast(2) - } - - if (normalized.endsWith("s") && normalized.length > 2 && !normalized.endsWith("ss")) { - return normalized.dropLast(1) - } - - return normalized - } - - private fun pluralizeNoun( - noun: String, - count: Int, - ): String { - if (count == 1) { - return noun - } - - if (noun == "search") { - return "searches" - } - - if (noun.endsWith( - "y", - ) && noun.length > 1 && !Regex("[aeiou]y$", RegexOption.IGNORE_CASE).containsMatchIn(noun) - ) { - return "${noun.dropLast(1)}ies" - } - - if (Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE).containsMatchIn(noun)) { - return "${noun}es" - } - - return "${noun}s" - } - - private fun countMetric( - count: Int, - noun: String, - ): Pair = count to singularizeNoun(noun).ifEmpty { "item" } - - private fun countFromRecord( - record: JsonObject, - fallbackNoun: String, - ): Pair? { - val nounByField = - mapOf( - "result_count" to "result", - "results_count" to "result", - "num_results" to "result", - "match_count" to "match", - "matches_count" to "match", - "file_count" to "file", - "files_count" to "file", - "item_count" to "item", - "items_count" to "item", - "search_count" to "search", - "searches_count" to "search", - "source_count" to "source", - "sources_count" to "source", - "document_count" to "document", - "documents_count" to "document", - "updated" to "item", - "added" to "item", - "removed" to "item", - "deleted" to "item", - "created" to "item", - "changed" to "item", - "processed" to "item", - "steps" to "step", - ) - val nounByArray = - mapOf( - "documents" to "document", - "files" to "file", - "items" to "item", - "matches" to "match", - "results" to "result", - "rows" to "row", - "sources" to "source", - ) - - for (key in COUNT_FIELD_KEYS) { - val count = countFromUnknown(record[key]) ?: continue - - return countMetric(count, nounByField[key] ?: fallbackNoun) - } - - for (key in COUNT_ARRAY_KEYS) { - val count = countFromUnknown(record[key]) ?: continue - - return countMetric(count, nounByArray[key] ?: fallbackNoun) - } - - for ((key, value) in record) { - if (key in COUNT_EXCLUDED_KEYS) { - continue - } - if (!Regex("_count$|_total$").containsMatchIn(key)) { - continue - } - - val count = countFromUnknown(value) ?: continue - val stripped = key.lowercase().replace(Regex("_(count|total)$"), "").replace(Regex("^num_"), "") - - return countMetric(count, singularizeNoun(stripped).ifEmpty { fallbackNoun }) - } - - return null - } - - private fun countFromText( - text: String, - fallbackNoun: String, - ): Pair? { - val t = text.trim() - if (t.isEmpty()) { - return null - } - - val unitMatch = - Regex( - """\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""", - RegexOption.IGNORE_CASE, - ).find(t) - ?: Regex( - """\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""", - RegexOption.IGNORE_CASE, - ).find(t) - - val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null - val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun - - return if (n > 0) countMetric(n, noun) else null - } - - private fun collectResultItems(value: JsonElement?): List { - if (value is JsonArray) { - return value.toList() - } - - val record = parseMaybeObject(value) ?: return emptyList() - - val keys = - listOf( - "web", - "results", - "search_results", - "sources", - "web_sources", - "items", - "organic_results", - "organic", - "matches", - "documents", - ) - - for (key in keys) { - val candidate = record[key] ?: continue - - if (candidate is JsonArray) { - return candidate.toList() - } - - if (candidate is JsonObject) { - val nested = collectResultItems(candidate) - - if (nested.isNotEmpty()) { - return nested - } - } - } - - val payload = unwrapToolPayload(record) - - return if (payload === record) emptyList() else collectResultItems(payload) - } - - private fun cleanVisibleText(text: String): String = - text - .replace(Regex("`{3,}"), "") - .replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "") - .replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" } - - private fun extractSearchResults( - result: JsonElement?, - limit: Int = 6, - ): List { - return collectResultItems(result) - .mapNotNull { item -> - val r = parseMaybeObject(item) ?: return@mapNotNull null - - SearchHit( - title = cleanVisibleText(firstString(r, listOf("title", "name"))), - url = firstString(r, listOf("url", "href", "link")), - snippet = cleanVisibleText(firstString(r, listOf("snippet", "description", "body"))), - ) - }.filter { it.title.isNotEmpty() || it.url.isNotEmpty() } - .take(limit) - } - - // ── detail helpers ─────────────────────────────────────────────────── - - private fun looksRedundant( - title: String, - detail: String, - ): Boolean { - if (detail.isEmpty()) { - return true - } - - val norm: (String) -> String = { input -> input.lowercase().replace(Regex("\\s+"), " ").trim() } - - return norm(title) == norm(detail) - } - - private fun fallbackDetailText( - args: JsonElement?, - result: JsonElement?, - ): String { - val argContext = contextValue(args) - val resultContext = contextValue(result) - - if (resultContext.isNotEmpty() && resultContext != argContext) { - return resultContext - } - - if (argContext.isNotEmpty()) { - return argContext - } - - if (result != null) { - return ToolResultSummary.formatToolResultSummary(result) - } - - return ToolResultSummary.formatToolResultSummary(args) - } - - private fun minimalValueSummary(value: JsonElement?): String { - if (value == null || value is JsonNull) { - return "" - } - - if (value is JsonPrimitive) { - return value.content - } - - return "" - } - - private fun shellOutput(result: JsonObject?): String { - val output = firstString(result, listOf("output", "stdout", "stderr")) - val lines = - (result?.get("lines") as? JsonArray) - ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content } - ?.joinToString("\n") - ?: "" - - return listOf(output, lines).filter { it.isNotEmpty() }.joinToString("\n") - } - - private fun stripDividerLines(value: String): String = - value - .split("\n") - .filter { !Regex("^[-=]{3,}\\s*$").matches(it.trim()) } - .joinToString("\n") - .trim() - - // ── the builder ────────────────────────────────────────────────────── + internal fun genericTitleFor(toolName: String?): String = ToolJson.titleForTool(toolName ?: "tool") fun build( toolName: String, @@ -824,25 +37,35 @@ object ToolViewBuilder { isError: Boolean = false, running: Boolean = false, ): ToolView { - val argsRecord = parseMaybeObject(args) - val resultRecord = parseMaybeObject(result) - val status = toolStatus(toolName, result, resultRecord, isError, running) + val call = ToolCall.of(toolName, args, result) + val renderer = ToolRendererRegistry.rendererFor(toolName) + + val status = ToolStatusResolver.status(call, isError, running) val error = if (status == ToolViewStatus.SUCCESS) { "" } else { - toolErrorText(toolName, isError, result, resultRecord) + ToolStatusResolver.errorText(call, isError) } val title = when { - running -> pendingTitle(toolName, argsRecord) - error.isNotEmpty() -> errorTitle(toolName, resultRecord) - else -> doneTitle(toolName, argsRecord, resultRecord) + running -> renderer.pendingTitle(call) + error.isNotEmpty() -> renderer.errorTitle(call) + else -> renderer.doneTitle(call) + } ?: ToolJson.titleForTool(toolName) + + val subtitle = + if (error.isNotEmpty()) { + error + } else { + renderer.subtitle(call) ?: genericSubtitle(call) } - val subtitle = subtitleFor(toolName, argsRecord, resultRecord, result, error) - val detailBody = stripDividerLines(detailFor(toolName, argsRecord, resultRecord, result, args)) + val detailBody = + ToolJson.stripDividerLines( + renderer.detail(call) ?: ToolJson.fallbackDetailText(call.rawArgs, call.rawResult), + ) val detail = if (error.isNotEmpty()) { listOf(error, detailBody) @@ -857,1267 +80,60 @@ object ToolViewBuilder { detailBody } - val rendersAnsi = toolName == "terminal" || toolName == "execute_code" - // The gateway's terminal_tool emits a single merged `output` string - // (stdout+stderr, ANSI-stripped); older payloads carried separate - // `stdout`/`stderr`. Prefer a real stream split, fall back to the - // merged output as the single renderable stream. - val stdout = if (rendersAnsi) firstString(resultRecord, listOf("stdout", "output")) else "" - val stderrRaw = if (rendersAnsi) firstString(resultRecord, listOf("stderr")) else "" - val hasSplitStreams = rendersAnsi && (stdout.isNotEmpty() || stderrRaw.isNotEmpty()) - - val searchHits = - if (toolName == "web_search" && status != ToolViewStatus.ERROR) { - extractSearchResults(result) - } else { - emptyList() - } - - val searchQuery = - if (toolName == "web_search") { - firstString(argsRecord, listOf("search_term", "query")).ifEmpty { contextValue(argsRecord) } - } else { - "" - } + val extras = renderer.extras(call, status) val countLabel = if (status == ToolViewStatus.ERROR) { null } else { - countLabelFor( - toolName, - argsRecord, - resultRecord, - result, - ) + ToolCounts.countLabelFor(call) } - val inlineDiff = if (isFileEditTool(toolName)) inlineDiffFromResult(result) else "" - val diffStats = if (inlineDiff.isNotEmpty()) countDiffLineStats(inlineDiff) else null return ToolView( status = status, title = title, subtitle = subtitle, detail = detail, - detailLabel = if (toolName == "web_search" && searchHits.isNotEmpty()) "Search results" else null, - countLabel = countLabel?.let { (count, noun) -> "$count ${pluralizeNoun(noun, count)}" }, - durationLabel = durationLabel(resultRecord), - stdout = if (hasSplitStreams) stdout else null, - stderr = if (hasSplitStreams) stderrRaw else null, - exitCode = if (toolName == "terminal") intValue(resultRecord?.get("exit_code")) else null, - terminalCommand = if (toolName == "terminal") shellCommand(argsRecord) else null, - inlineDiff = inlineDiff.ifEmpty { null }, - diffPath = if (inlineDiff.isNotEmpty()) fileEditPath(argsRecord, resultRecord) else null, - diffStats = diffStats, - imageUrl = imageUrlFor(argsRecord, resultRecord), - searchHits = searchHits.ifEmpty { null }, - searchQuery = searchQuery.ifEmpty { null }, + detailLabel = extras.detailLabel, + countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" }, + durationLabel = ToolJson.durationLabel(call.result), + stdout = extras.stdout, + stderr = extras.stderr, + exitCode = extras.exitCode, + terminalCommand = extras.terminalCommand, + inlineDiff = extras.inlineDiff?.ifEmpty { null }, + diffPath = extras.diffPath, + diffStats = extras.diffStats, + imageUrl = imageUrlFor(call.args, call.result), + searchHits = extras.searchHits, + searchQuery = extras.searchQuery, error = error.ifEmpty { null }, ) } - // ── titles per tool ────────────────────────────────────────────────── + /** Subtitle fallback: first line of the heuristic summary, then contexts. */ + private fun genericSubtitle(call: ToolCall): String { + val summary = ToolResultSummary.formatToolResultSummary(call.rawResult) + val firstLine = summary.split("\n").firstOrNull()?.takeIf { it.isNotEmpty() } ?: "" - private fun pendingTitle( - toolName: String, - args: JsonObject?, - ): String = - when (toolName) { - "web_extract" -> { - "Reading ${targetHostname(toolName, args, null)}" - } - - "browser_navigate" -> { - "Opening ${targetHostname(toolName, args, null)}" - } - - "web_search" -> { - "Searching \"${compactPreview(searchQueryFor(args), 48)}\"" - } - - "read_file" -> { - "Reading ${readFileDisplayTarget(args, null)}" - } - - "terminal", "execute_code" -> { - val command = shellCommand(args) - val verb = if (toolName == "execute_code") "Running code" else "Running" - if (command.isNotEmpty()) { - "$verb ${compactPreview( - CommandSummarizer.summarizeShellCommand(command), - 160, - )}" - } else { - titleForTool(toolName) - } - } - - "memory" -> { - "Saving" - } - - else -> { - titleForTool(toolName) - } - } - - private fun doneTitle( - toolName: String, - args: JsonObject?, - result: JsonObject?, - ): String = - when (toolName) { - "web_extract" -> { - "Read ${targetHostname(toolName, args, result)}" - } - - "browser_navigate" -> { - "Opened ${targetHostname(toolName, args, result)}" - } - - "web_search" -> { - "Searched \"${compactPreview(searchQueryFor(args), 48)}\"" - } - - "read_file" -> { - "Read ${readFileDisplayTarget(args, result)}" - } - - "terminal", "execute_code" -> { - val command = shellCommand(args) - val verb = if (toolName == "execute_code") "Ran code" else "Ran" - if (command.isNotEmpty()) { - "$verb ${compactPreview( - CommandSummarizer.summarizeShellCommand(command), - 160, - )}" - } else { - titleForTool(toolName) - } - } - - "memory" -> { - val action = firstString(args, listOf("action")).lowercase() - val target = firstString(args, listOf("target")) - val base = - when (action) { - "replace", "update" -> "Memory Updated" - "remove", "delete" -> "Memory Removed" - "add" -> "Memory Saved" - else -> "Memory ${firstString(args, listOf("action"))}" - } - - // Keep the target visible — "Memory Saved (user)" — like the - // pre-engine mobile summary did. - if (target.isNotEmpty()) "$base ($target)" else base - } - - "cronjob" -> { - "Cron ${firstString(args, listOf("action")).ifEmpty { "manage" }}" - } - - "edit_file", "patch", "write_file" -> { - val path = fileEditPath(args, result) - if (path.isNotEmpty()) fileEditBasename(path) else titleForTool(toolName) - } - - else -> { - titleForTool(toolName) - } - } - - private fun errorTitle( - toolName: String, - result: JsonObject?, - ): String = - when (toolName) { - "browser_navigate" -> { - val url = findFirstUrl(result).ifEmpty { result?.get("url")?.toString() ?: "" } - if (url.isNotEmpty()) "Failed to open ${hostnameOf(url)}" else titleForTool(toolName) - } - - else -> { - titleForTool(toolName) - } - } - - private fun targetHostname( - toolName: String, - args: JsonObject?, - result: JsonObject?, - ): String { - val url = - firstString(args, listOf("url", "target")) - .ifEmpty { firstString(result, listOf("url")) } - .ifEmpty { findFirstUrl(args, result) } - - return if (url.isNotEmpty()) hostnameOf(url) else "page" + return ToolJson + .compactPreview(firstLine, 120) + .ifEmpty { ToolJson.compactPreview(call.result, 120) } + .ifEmpty { ToolJson.compactPreview(call.args, 120) } + .ifEmpty { ToolJson.fallbackDetailText(call.args, call.rawResult) } } - private fun searchQueryFor(args: JsonObject?): String = - firstString(args, listOf("search_term", "query")).ifEmpty { contextValue(args) } - - // ── subtitles per tool ─────────────────────────────────────────────── - - private fun subtitleFor( - toolName: String, - args: JsonObject?, - result: JsonObject?, - rawResult: JsonElement?, - error: String, - ): String { - if (error.isNotEmpty()) { - return error + private fun looksRedundant( + title: String, + detail: String, + ): Boolean { + if (detail.isEmpty()) { + return true } - return when (toolName) { - "browser_navigate" -> { - val url = - firstString(args, listOf("url", "target")) - .ifEmpty { firstString(result, listOf("url")) } - .ifEmpty { findFirstUrl(args, result) } + val norm: (String) -> String = { input -> input.lowercase().replace(Regex("\\s+"), " ").trim() } - if (url.isNotEmpty()) hostnameOf(url) else "Navigated in browser" - } - - "browser_snapshot" -> { - val snapshot = firstString(result, listOf("snapshot")) - if (snapshot.isNotEmpty()) { - summarizeBrowserSnapshot( - snapshot, - ) - } else { - "Captured a browser accessibility snapshot" - } - } - - "browser_click" -> { - val clicked = - firstString( - result, - listOf("clicked"), - ).ifEmpty { firstString(args, listOf("ref", "target")) } - - if (clicked.isEmpty()) { - "Clicked on page" - } else if (clicked.startsWith("@")) { - "Clicked page element (internal ref $clicked)" - } else { - "Clicked $clicked" - } - } - - "browser_fill", "browser_type" -> { - val field = firstString(args, listOf("label", "field", "ref", "target")) - val value = firstString(args, listOf("value", "text")) - listOf( - field.takeIf { it.isNotEmpty() }?.let { "Field: $it" }, - value.takeIf { it.isNotEmpty() }?.let { "Value: ${compactPreview(it, 42)}" }, - ).filterNotNull() - .joinToString(" · ") - .ifEmpty { "Filled page input" } - } - - "web_search" -> { - searchQueryFor( - args, - ).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources" - } - - "terminal", "execute_code" -> { - val output = shellOutput(result) - val firstMeaningfulLine = - output - .split("\n") - .map { it.trim() } - .firstOrNull { it.isNotEmpty() } - - if (firstMeaningfulLine != null) { - compactPreview(firstMeaningfulLine, 160) - } else { - // A terminal row with no output shows its command in the - // title; nothing more to add. - "" - } - } - - "read_file", "edit_file", "patch", "write_file" -> { - val path = - if (isFileEditTool(toolName)) { - fileEditPath(args, result) - } else { - firstString(args, listOf("path", "file", "filepath")) - } - - if (path.isNotEmpty()) { - path - } else { - fallbackDetailText(args, rawResult) - } - } - - "web_extract" -> { - val url = - firstString(args, listOf("url")) - .ifEmpty { firstString(result, listOf("url")) } - .ifEmpty { findFirstUrl(args, result) } - - if (url.isNotEmpty()) hostnameOf(url) else "Fetched webpage" - } - - "memory" -> { - firstString(result, listOf("message", "error")) - } - - "cronjob" -> { - cronjobSubtitle(args, result) - } - - "todo" -> { - todoSubtitle(result) - } - - "fact_store" -> { - factStoreSubtitle(args, result) - } - - "session_search" -> { - sessionSearchSubtitle(result) - } - - "skills_list" -> { - val category = firstString(args, listOf("category")) - val count = - (result?.get("skills") as? JsonArray)?.size - ?: (result?.get("results") as? JsonArray)?.size - ?: 0 - if (count > 0) { - "$count skills${category.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: ""}" - } else { - "No skills found" - } - } - - "skill_view" -> { - firstString(args, listOf("name")) - } - - "skill_manage" -> { - val action = firstString(args, listOf("action")) - val name = firstString(args, listOf("name")) - if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action" - } - - "process" -> { - val action = firstString(args, listOf("action")) - val procId = firstString(args, listOf("session_id")) - if (procId.isNotEmpty()) "$action: $procId" else action - } - - "x_search" -> { - val query = firstString(args, listOf("query")) - val degraded = - ( - result?.get( - "degraded", - ) as? JsonPrimitive - )?.let { !it.isString && it.content == "true" } == true - if (query.isNotEmpty()) "$query${if (degraded) " (no citations)" else ""}" else "Queried X" - } - - "vision_analyze" -> { - firstString(args, listOf("image_url")).take(60) - } - - "tool_search" -> { - val query = firstString(args, listOf("query")) - val matches = (result?.get("matches") as? JsonArray)?.size - if (matches != null) "$query ($matches matches)" else query - } - - "image_generate" -> { - val imageUrl = firstString(result, listOf("image")).ifEmpty { firstString(args, listOf("image_url")) } - val modality = firstString(result, listOf("modality")) - listOf(imageUrl.take(60), modality.takeIf { it.isNotEmpty() }?.let { "($it)" }) - .filterNotNull() - .joinToString(" ") - } - - "project_list" -> { - val projects = (result?.get("projects") as? JsonArray)?.size - val active = firstString(result, listOf("active_id")) - if (projects != null) { - "$projects projects${active.takeIf { it.isNotEmpty() }?.let { " (1 active)" } ?: ""}" - } else { - "Projects" - } - } - - "project_create", "project_switch" -> { - val actionLabel = if (toolName == "project_create") "Created" else "Switched to" - val name = firstString(result, listOf("name")) - if (name.isNotEmpty()) "$actionLabel $name" else actionLabel - } - - "read_terminal" -> { - val error = firstString(result, listOf("error")) - if (error.isNotEmpty()) { - "❌ $error" - } else { - val total = intValue(result?.get("total_lines")) ?: 0 - val start = intValue(result?.get("start")) ?: 0 - val end = intValue(result?.get("end")) ?: 0 - val text = firstString(result, listOf("text")) - if (text.isNotEmpty()) { - val cursor = intValue(result?.get("cursor_row")) - "Lines $start-$end of $total${cursor?.let { ", cursor at row $it" } ?: ""}" - } else { - "Terminal output" - } - } - } - - "computer_use" -> { - computerUseSubtitle(args, result) - } - - else -> { - val summary = ToolResultSummary.formatToolResultSummary(rawResult) - val firstLine = summary.split("\n").firstOrNull()?.takeIf { it.isNotEmpty() } ?: "" - compactPreview(firstLine, 120) - .ifEmpty { compactPreview(result, 120) } - .ifEmpty { compactPreview(args, 120) } - .ifEmpty { fallbackDetailText(args, rawResult) } - } - } - } - - private fun cronjobSubtitle( - args: JsonObject?, - result: JsonObject?, - ): String { - val jobs = result?.get("jobs") as? JsonArray - - if (jobs != null) { - return if (jobs.isNotEmpty()) { - "${jobs.size} cron ${if (jobs.size == 1) "job" else "jobs"}" - } else { - "No cron jobs" - } - } - - val message = firstString(result, listOf("message")) - if (message.isNotEmpty()) { - return message - } - - val action = firstString(args, listOf("action")).ifEmpty { "manage" } - val name = firstString(result, listOf("name")).ifEmpty { firstString(args, listOf("name", "job_id")) } - val label = action.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } - - return if (name.isNotEmpty()) "$label $name" else "Cron $action" - } - - private fun todoSubtitle(result: JsonObject?): String { - val summary = result?.get("summary") as? JsonObject ?: return "" - val total = intValue(summary["total"]) ?: 0 - val pending = intValue(summary["pending"]) ?: 0 - val inProgress = intValue(summary["in_progress"]) ?: 0 - val completed = intValue(summary["completed"]) ?: 0 - val cancelled = intValue(summary["cancelled"]) ?: 0 - - val parts = - listOf( - pending.takeIf { it > 0 }?.let { "$it pending" }, - inProgress.takeIf { it > 0 }?.let { "$it in_progress" }, - completed.takeIf { it > 0 }?.let { "$it completed" }, - cancelled.takeIf { it > 0 }?.let { "$it cancelled" }, - ).filterNotNull() - - val itemWord = if (total == 1) "item" else "items" - - return if (parts.isEmpty()) "0 items" else "$total $itemWord (${parts.joinToString(", ")})" - } - - private fun factStoreSubtitle( - args: JsonObject?, - result: JsonObject?, - ): String { - val action = firstString(args, listOf("action")).ifEmpty { firstString(result, listOf("action")) } - val entity = firstString(args, listOf("entity")) - val query = firstString(args, listOf("query")) - val facts = - (result?.get("results") as? JsonArray) - ?: (result?.get("facts") as? JsonArray) - val count = intValue(result?.get("count")) ?: facts?.size ?: 0 - val status = firstString(result, listOf("status")) - val factId = intValue(result?.get("fact_id")) - val removed = (result?.get("removed") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true - val updated = (result?.get("updated") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true - - val actionContext = - when { - entity.isNotEmpty() -> " ($action: $entity)" - query.isNotEmpty() -> " ($action: $query)" - else -> " ($action)" - } - - return when { - status == "added" && factId != null -> "Fact added (ID: $factId)" - status == "added" -> "Fact added" - removed -> "Fact removed" - updated -> "Fact updated" - facts != null -> "$count facts$actionContext" - else -> action - } - } - - private fun sessionSearchSubtitle(result: JsonObject?): String { - val mode = firstString(result, listOf("mode")) - val query = firstString(result, listOf("query")) - val count = intValue(result?.get("count")) - val msgCount = intValue(result?.get("message_count")) - val messages = result?.get("messages") as? JsonArray - val truncated = - ( - result?.get( - "truncated", - ) as? JsonPrimitive - )?.let { !it.isString && it.content == "true" } == true - - return when (mode) { - "discover" -> "${count ?: 0} session${if (count == 1) "" else "s"}${query.takeIf { it.isNotEmpty() }?.let { - ": ${it.take( - 60, - )}" - } ?: ""}" - - "scroll" -> "${messages?.size ?: 0} messages (scroll)" - - "read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}" - - "browse" -> "${count ?: 0} recent sessions" - - else -> "session_search" - } - } - - private fun computerUseSubtitle( - args: JsonObject?, - result: JsonObject?, - ): String { - val error = firstString(result, listOf("error")) - if (error.isNotEmpty()) { - return "❌ $error" - } - - val apps = result?.get("apps") as? JsonArray - if (apps != null) { - return "${apps.size} apps" - } - - val elements = result?.get("elements") as? JsonArray - if (elements != null) { - val mode = firstString(result, listOf("mode")) - val width = intValue(result.get("width")) - val height = intValue(result.get("height")) - val modePart = mode.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: "" - val sizePart = if (width != null && height != null) " ${width}x$height" else "" - return "${elements.size} elements$modePart$sizePart" - } - - val action = firstString(args, listOf("action")) - if (action.isNotEmpty()) { - val ok = (result?.get("ok") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } - val fail = (result?.get("ok") as? JsonPrimitive)?.let { !it.isString && it.content == "false" } - return "$action${when { - ok == true -> " ✓" - fail == true -> " ✗" - else -> "" - }}" - } - - return "Computer use" - } - - // ── details per tool ───────────────────────────────────────────────── - - private fun detailFor( - toolName: String, - args: JsonObject?, - result: JsonObject?, - rawResult: JsonElement?, - rawArgs: JsonElement?, - ): String { - return when (toolName) { - "browser_snapshot" -> { - val snapshot = firstString(result, listOf("snapshot")) - if (snapshot.isNotEmpty()) { - summarizeBrowserSnapshot( - snapshot, - ) - } else { - fallbackDetailText(rawArgs, rawResult) - } - } - - "terminal", "execute_code" -> { - val output = shellOutput(result) - if (output.isNotEmpty()) { - output - } else if (toolName == "execute_code") { - fallbackDetailText(rawArgs, rawResult) - } else { - // A terminal row with no output already shows its command - // in the title; the generic fallback would repeat it. - "" - } - } - - "web_extract" -> { - val direct = firstString(result, listOf("content", "text", "markdown", "body", "summary", "message")) - if (direct.isNotEmpty()) { - return direct.replace(Regex("\\s*in\\s+\\d+(?:\\.\\d+)?s\\s*$"), "").trim() - } - - val results = result?.get("results") as? JsonArray - if (results != null) { - return results - .mapNotNull { item -> - val row = parseMaybeObject(item) ?: return@mapNotNull null - firstString(row, listOf("content", "text", "markdown", "body")) - }.filter { it.isNotEmpty() } - .joinToString("\n\n---\n\n") - } - - fallbackDetailText(rawArgs, rawResult) - } - - "read_file" -> { - if (rawResult == null) { - return "" - } - firstString(result, listOf("content", "text", "data", "body")) - } - - "memory" -> { - firstString(result, listOf("message", "error")) - } - - "edit_file", "patch", "write_file" -> { - if (inlineDiffFromResult(rawResult).isNotEmpty()) { - "" - } else { - firstString(result, listOf("message", "summary")).ifEmpty { - if (fileEditPath(args, result).isNotEmpty()) "" else fallbackDetailText(rawArgs, rawResult) - } - } - } - - "web_search" -> { - fallbackDetailText(rawArgs, rawResult) - } - - "cronjob" -> { - cronjobDetail(args, result) - } - - "todo" -> { - todoDetail(result) ?: "" - } - - "fact_store" -> { - factStoreDetail(result) ?: "" - } - - "session_search" -> { - sessionSearchDetail(result) ?: "" - } - - "skills_list" -> { - val skills = - (result?.get("skills") as? JsonArray) - ?: (result?.get("results") as? JsonArray) - skills - ?.mapNotNull { el -> - val s = parseMaybeObject(el) ?: return@mapNotNull null - val name = firstString(s, listOf("name")) - val desc = firstString(s, listOf("description")) - val cat = firstString(s, listOf("category")) - val lines = mutableListOf("📌 $name") - if (desc.isNotEmpty()) lines += " $desc" - if (cat.isNotEmpty()) lines += " [$cat]" - lines.joinToString("\n") - }?.joinToString("\n\n") - ?: "No skills found" - } - - "skill_view" -> { - val content = firstString(result, listOf("content")) - val linkedFiles = result?.get("linked_files") as? JsonObject - val contentPreview = - if (content.length > 500) { - "${content.take( - 500, - )}\n... [${content.length - 500} more chars]" - } else { - content - } - val filesInfo = - linkedFiles - ?.entries - ?.joinToString("\n") { (k, v) -> - val paths = - when (v) { - is JsonArray -> { - v.joinToString( - ", ", - ) { (it as? JsonPrimitive)?.content ?: it.toString() } - } - - is JsonPrimitive -> { - v.content - } - - else -> { - v.toString() - } - } - " 📎 $k: $paths" - } - - buildString { - if (contentPreview.isNotEmpty()) append(contentPreview) - if (filesInfo != null) { - if (contentPreview.isNotEmpty()) append("\n\n") - append("━━━ Linked Files ━━━\n$filesInfo") - } - if (content.isEmpty() && linkedFiles == null) append("No content available") - } - } - - "skill_manage" -> { - val error = firstString(result, listOf("error")) - if (error.isNotEmpty()) { - "❌ $error" - } else { - val msg = firstString(result, listOf("message")) - val success = - ( - result?.get( - "success", - ) as? JsonPrimitive - )?.let { !it.isString && it.content == "false" } == true - when { - !success -> "❌ Skill operation failed" - msg.isNotEmpty() -> "✅ $msg" - else -> "✅ Skill operation done" - } - } - } - - "process" -> { - processDetail(args, result) - } - - "x_search" -> { - xSearchDetail(result) - } - - "vision_analyze" -> { - val error = firstString(result, listOf("error")) - val description = firstString(result, listOf("description")) - val content = firstString(result, listOf("content")) - when { - error.isNotEmpty() -> "❌ $error" - description.isNotEmpty() -> description - content.isNotEmpty() -> content - else -> "No description available" - } - } - - "tool_search" -> { - val matches = result?.get("matches") as? JsonArray - matches - ?.mapNotNull { el -> - val m = parseMaybeObject(el) ?: return@mapNotNull null - val name = firstString(m, listOf("name")) - val desc = firstString(m, listOf("description")) - val lines = mutableListOf("🔧 $name") - if (desc.isNotEmpty()) lines += " $desc" - lines.joinToString("\n") - }?.joinToString("\n\n") - ?: "No matching tools" - } - - "image_generate" -> { - val error = firstString(result, listOf("error")) - val imageUrl = firstString(result, listOf("image")) - val prompt = firstString(args, listOf("prompt")) - - buildString { - if (error.isNotEmpty()) { - append("❌ $error") - if (imageUrl.isNotEmpty()) append("\n🔗 $imageUrl") - } else if (imageUrl.isNotEmpty()) { - append("🖼️ ") - if (prompt.isNotEmpty()) append("$prompt\n") - append("\n🔗 $imageUrl") - } else { - append("✅ Generated") - } - } - } - - "project_list" -> { - val projects = result?.get("projects") as? JsonArray - projects - ?.mapNotNull { el -> - val p = parseMaybeObject(el) ?: return@mapNotNull null - val id = firstString(p, listOf("id")) - val name = firstString(p, listOf("name")) - val slug = firstString(p, listOf("slug")) - val path = firstString(p, listOf("primary_path")) - val isActive = - (p["active"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true - val lines = mutableListOf("${if (isActive) "⭐ " else "📁 "}$name") - if (slug.isNotEmpty()) lines += " 🏷️ $slug" - if (path.isNotEmpty()) lines += " 📍 $path" - if (isActive) lines += " ✅ ACTIVE PROJECT" - lines.joinToString("\n") - }?.joinToString("\n\n") - ?: "" - } - - "project_create", "project_switch" -> { - val error = firstString(result, listOf("error")) - val actionLabel = if (toolName == "project_create") "Created" else "Switched to" - val name = firstString(result, listOf("name")) - val slug = firstString(result, listOf("slug")) - val path = firstString(result, listOf("primary_path")) - - buildString { - if (error.isNotEmpty()) { - append("❌ $error") - } else { - append("✅ $actionLabel") - if (name.isNotEmpty()) append(": $name") - if (slug.isNotEmpty()) append("\n 🏷️ $slug") - if (path.isNotEmpty()) append("\n 📍 $path") - } - } - } - - "read_terminal" -> { - readTerminalDetail(result) - } - - "computer_use" -> { - computerUseDetail(args, result) - } - - else -> { - fallbackDetailText(rawArgs, rawResult) - } - } - } - - private fun cronjobDetail( - args: JsonObject?, - result: JsonObject?, - ): String { - val jobs = result?.get("jobs") as? JsonArray - - if (jobs != null) { - if (jobs.isEmpty()) { - return "No cron jobs scheduled" - } - - return jobs - .take(20) - .mapNotNull { job -> - val row = parseMaybeObject(job) ?: return@mapNotNull null - val name = firstString(row, listOf("name", "id")).ifEmpty { "job" } - val sched = firstString(row, listOf("schedule_display", "schedule")) - if (sched.isNotEmpty()) "- $name · $sched" else "- $name" - }.joinToString("\n") - } - - val rows = - listOf( - "Schedule" to firstString(result, listOf("schedule")), - "Repeat" to firstString(result, listOf("repeat")), - "Delivery" to firstString(result, listOf("deliver")), - "Next run" to firstString(result, listOf("next_run_at")), - ).filter { it.second.isNotEmpty() } - - return if (rows.isNotEmpty()) { - rows.joinToString( - "\n", - ) { (k, v) -> "$k: $v" } - } else { - fallbackDetailText(args, result) - } - } - - private fun todoDetail(result: JsonObject?): String? { - val todos = result?.get("todos") as? JsonArray ?: return null - - return todos - .mapNotNull { el -> - val item = parseMaybeObject(el) ?: return@mapNotNull null - val id = firstString(item, listOf("id")) - val content = firstString(item, listOf("content")) - val status = firstString(item, listOf("status")).ifEmpty { "pending" } - val parent = firstString(item, listOf("parent")) - val marker = - when (status) { - "completed" -> "[x]" - "in_progress" -> "[>]" - "cancelled" -> "[~]" - else -> "[ ]" - } - val indent = if (parent.isNotEmpty()) " ↳ " else "" - "$indent$marker $id. $content" - }.joinToString("\n") - .takeIf { it.isNotEmpty() } - } - - private fun factStoreDetail(result: JsonObject?): String? { - val facts = - (result?.get("results") as? JsonArray) - ?: (result?.get("facts") as? JsonArray) - ?: return null - - return facts - .mapNotNull { el -> - val item = parseMaybeObject(el) ?: return@mapNotNull null - val fid = intValue(item["fact_id"]) ?: 0 - val content = firstString(item, listOf("content")) - val category = firstString(item, listOf("category")) - val trust = numberValue(item["trust_score"]) - val tags = firstString(item, listOf("tags")) - - val metaParts = mutableListOf() - if (category.isNotEmpty()) metaParts += "[$category]" - if (trust != null) metaParts += "trust: ${"%.2f".format(trust)}" - if (tags.isNotEmpty()) metaParts += "🏷️ $tags" - - if (metaParts.isNotEmpty()) { - "#$fid $content\n ${metaParts.joinToString(" ")}" - } else { - "#$fid $content" - } - }.joinToString("\n") - .takeIf { it.isNotEmpty() } - } - - private fun sessionSearchDetail(result: JsonObject?): String? { - val results = result?.get("results") as? JsonArray - val messages = result?.get("messages") as? JsonArray - - val formattedResults = - results - ?.mapIndexedNotNull { idx, el -> - val item = parseMaybeObject(el) ?: return@mapIndexedNotNull null - val whenField = firstString(item, listOf("when", "started_at")) - val source = firstString(item, listOf("source")) - val title = firstString(item, listOf("title")) - val snippet = firstString(item, listOf("snippet")).ifEmpty { firstString(item, listOf("preview")) } - val model = firstString(item, listOf("model")) - val matchedRole = firstString(item, listOf("matched_role")) - val sessionMsgCount = intValue(item["message_count"]) - - val header = whenField.takeIf { it.isNotEmpty() }?.let { "📅 $it" } ?: "" - val sourceTag = source.takeIf { it.isNotEmpty() }?.let { "[$it]" } ?: "" - val titleLine = title.takeIf { it.isNotEmpty() }?.let { "\n 📄 $it" } ?: "" - val modelLine = model.takeIf { it.isNotEmpty() }?.let { "\n 🤖 $it" } ?: "" - val matchedLine = matchedRole.takeIf { it.isNotEmpty() }?.let { "\n 🎯 matched: $it" } ?: "" - val countLine = sessionMsgCount?.let { "\n $it msgs" } ?: "" - val snippetLine = - snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: "" - - "━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine" - }?.joinToString("\n") - ?.takeIf { it.isNotEmpty() } - - val anchorId = intValue(result?.get("around_message_id")) - - val formattedMessages = - messages - ?.mapNotNull { el -> - val item = parseMaybeObject(el) ?: return@mapNotNull null - val msgId = intValue(item["id"]) - val role = firstString(item, listOf("role")).ifEmpty { "?" } - val content = firstString(item, listOf("content")) - val toolName = firstString(item, listOf("tool_name")) - - val roleEmoji = - when (role) { - "user" -> "👤" - "assistant" -> "🤖" - "tool" -> "🔧" - else -> "❓" - } - val namePart = if (toolName.isNotEmpty()) " ($toolName)" else "" - val anchor = if (anchorId != null && msgId == anchorId) " ⬅️" else "" - val cleanContent = content.take(300).replace("\n", " ") - - "[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent" - }?.joinToString("\n") - ?.takeIf { it.isNotEmpty() } - - return formattedResults ?: formattedMessages - } - - private fun processDetail( - args: JsonObject?, - result: JsonObject?, - ): String { - val error = firstString(result, listOf("error")) - if (error.isNotEmpty()) { - return "❌ $error" - } - - val processes = result?.get("processes") as? JsonArray - if (processes != null) { - return processes - .mapNotNull { el -> - val p = parseMaybeObject(el) ?: return@mapNotNull null - val pid = firstString(p, listOf("session_id")).ifEmpty { firstString(p, listOf("id")) } - val pStatus = firstString(p, listOf("status")) - val cmd = firstString(p, listOf("command")) - val running = (p["running"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } - val parts = mutableListOf("📌 $pid${cmd.takeIf { it.isNotEmpty() }?.let { ": $it" } ?: ""}") - if (pStatus.isNotEmpty()) parts += " Status: $pStatus" - if (running != null) parts += " Running: $running" - parts.joinToString("\n") - }.joinToString("\n\n") - } - - val output = firstString(result, listOf("output")) - if (output.isNotEmpty()) { - val status = firstString(result, listOf("status")) - return "${status.takeIf { it.isNotEmpty() }?.let { "Status: $it\n" } ?: ""}$output" - } - - val status = firstString(result, listOf("status")) - if (status.isNotEmpty()) { - return "✅ $status" - } - - val action = firstString(args, listOf("action")) - return "✅ ${action.ifEmpty { "done" }} done" - } - - private fun xSearchDetail(result: JsonObject?): String { - val error = firstString(result, listOf("error")) - val answer = firstString(result, listOf("answer")) - val citations = result?.get("citations") as? JsonArray - val degraded = (result?.get("degraded") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true - - return when { - error.isNotEmpty() -> { - "❌ $error" - } - - answer.isNotEmpty() -> { - val lines = mutableListOf(answer) - if (citations != null && citations.isNotEmpty()) { - lines += "\n━━━ Citations ━━━" - citations.forEachIndexed { idx, cit -> - val c = parseMaybeObject(cit) ?: return@forEachIndexed - val url = firstString(c, listOf("url")) - val title = firstString(c, listOf("title")) - lines += "${idx + 1}. ${title.ifEmpty { url.ifEmpty { "source" } }}" - if (title.isNotEmpty() && url.isNotEmpty()) lines += " $url" - } - } - if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge" - lines.joinToString("\n") - } - - else -> { - "No results" - } - } - } - - private fun readTerminalDetail(result: JsonObject?): String { - val error = firstString(result, listOf("error")) - val text = firstString(result, listOf("text")) - val total = intValue(result?.get("total_lines")) ?: 0 - val start = intValue(result?.get("start")) ?: 0 - val end = intValue(result?.get("end")) ?: 0 - val cursor = intValue(result?.get("cursor_row")) - - return buildString { - if (error.isNotEmpty()) { - append("❌ $error") - } else if (text.isNotEmpty()) { - append("━━━ Terminal ━━━ ($start:$end / $total)") - if (cursor != null) append(" │ cursor row $cursor") - append("\n$text") - } else { - append("No terminal output") - } - } - } - - private fun computerUseDetail( - args: JsonObject?, - result: JsonObject?, - ): String { - val error = firstString(result, listOf("error")) - if (error.isNotEmpty()) { - return "❌ $error" - } - - val isMultimodal = - ( - result?.get( - "_multimodal", - ) as? JsonPrimitive - )?.let { !it.isString && it.content == "true" } == true - val content = result?.get("content") as? JsonArray - val multimodalText = - if (isMultimodal && content != null) { - content - .mapNotNull { el -> - val e = parseMaybeObject(el) ?: return@mapNotNull null - if (firstString(e, listOf("type")) == "text") firstString(e, listOf("text")) else null - }.joinToString("\n") - } else { - "" - } - - val textSummary = firstString(result, listOf("summary")) - val visionAnalysis = firstString(result, listOf("vision_analysis")) - val apps = result?.get("apps") as? JsonArray - val elements = result?.get("elements") as? JsonArray - val mode = firstString(result, listOf("mode")) - val width = intValue(result?.get("width")) - val height = intValue(result?.get("height")) - val action = firstString(args, listOf("action")) - val ok = (result?.get("ok") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } - val fail = (result?.get("ok") as? JsonPrimitive)?.let { !it.isString && it.content == "false" } - val message = firstString(result, listOf("message")) - - val body = - buildString { - val textPart = - multimodalText - .ifEmpty { - firstString( - result, - listOf("text_summary"), - ) - }.ifEmpty { textSummary } - if (textPart.isNotEmpty()) { - append(textPart) - if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) { - append("\n\n$textSummary") - } - } - - if (apps != null) { - if (isNotEmpty()) append("\n\n") - apps.forEachIndexed { idx, el -> - val obj = parseMaybeObject(el) - val name = firstString(obj, listOf("name")).ifEmpty { "?" } - val pid = intValue(obj?.get("pid")) - append("${idx + 1}. $name") - if (pid != null) append(" (PID: $pid)") - append("\n") - } - } else if (elements != null) { - if (isNotEmpty()) append("\n\n") - append("🖥️ ${elements.size} interactable elements") - if (mode.isNotEmpty()) append(" (mode: $mode)") - if (width != null && height != null) append(" • ${width}x$height") - if (textSummary.isNotEmpty()) append("\n\n$textSummary") - val previewCount = minOf(elements.size, 5) - if (previewCount > 0) { - append("\n\n") - for (i in 0 until previewCount) { - val obj = parseMaybeObject(elements[i]) - val idx = intValue(obj?.get("index")) - val role = firstString(obj, listOf("role")) - val label = firstString(obj, listOf("label")) - append(" #$idx ${role}${label.takeIf { it.isNotEmpty() }?.let { " '$it'" } ?: ""}\n") - } - if (elements.size > previewCount) { - append(" ... and ${elements.size - previewCount} more") - } - } - } else if (action.isNotEmpty() && multimodalText.isEmpty() && - firstString( - result, - listOf("text_summary"), - ).isEmpty() && textSummary.isEmpty() - ) { - append("$action:") - when { - ok == true -> append(" ✅ ok") - fail == true -> append(" ❌ failed") - } - if (message.isNotEmpty()) append("\n$message") - } else if (isEmpty()) { - append("Computer use action") - } - } - - return if (visionAnalysis.isNotEmpty()) "$body\n\n━━━ Vision Analysis ━━━\n$visionAnalysis" else body - } - - // ── count label ────────────────────────────────────────────────────── - - private fun countLabelFor( - toolName: String, - args: JsonObject?, - result: JsonObject?, - rawResult: JsonElement?, - ): Pair? { - if (rawResult == null) { - return null - } - - val fallbackNoun = - when (toolName) { - "browser_snapshot", "todo" -> "item" - "list_files" -> "file" - "search_files", "session_search_recall", "web_search" -> "result" - "skills_list" -> "skill" - else -> "item" - } - - if (toolName == "web_search") { - val hits = collectResultItems(rawResult) - - if (hits.isNotEmpty()) { - return countMetric(hits.size, "result") - } - } - - val direct = countFromRecord(result ?: JsonObject(emptyMap()), fallbackNoun) - if (direct != null) { - return if (toolName == "web_search") countMetric(direct.first, "result") else direct - } - - val payload = unwrapToolPayload(rawResult) - if (payload !== rawResult && payload is JsonObject) { - val payloadCount = countFromRecord(payload, fallbackNoun) - - if (payloadCount != null) { - return if (toolName == "web_search") countMetric(payloadCount.first, "result") else payloadCount - } - } - - val summaryText = - firstString(result, listOf("summary", "message", "detail")).ifEmpty { fallbackDetailText(args, rawResult) } - val textMetric = countFromText(summaryText, fallbackNoun) - - return textMetric + return norm(title) == norm(detail) } private fun imageUrlFor( @@ -2125,8 +141,9 @@ object ToolViewBuilder { result: JsonObject?, ): String? { val candidate = - firstString(result, listOf("image_url", "url", "path", "image_path")) - .ifEmpty { firstString(args, listOf("image_url", "url", "path")) } + ToolJson + .firstString(result, listOf("image_url", "url", "path", "image_path")) + .ifEmpty { ToolJson.firstString(args, listOf("image_url", "url", "path")) } if (candidate.isEmpty()) { return null diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt new file mode 100644 index 00000000..e459bbce --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt @@ -0,0 +1,106 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer + +/** `browser_navigate`: hostname titles, including the failed-to-open form. */ +internal object BrowserNavigateRenderer : ToolRenderer { + override fun pendingTitle(call: ToolCall): String = "Opening ${targetHostname(call.args, null)}" + + override fun doneTitle(call: ToolCall): String = "Opened ${targetHostname(call.args, call.result)}" + + override fun errorTitle(call: ToolCall): String? { + val url = + ToolJson + .findFirstUrl(call.result) + .ifEmpty { call.result?.get("url")?.toString() ?: "" } + + return if (url.isNotEmpty()) "Failed to open ${ToolJson.hostnameOf(url)}" else null + } + + override fun subtitle(call: ToolCall): String { + val url = + ToolJson + .firstString(call.args, listOf("url", "target")) + .ifEmpty { ToolJson.firstString(call.result, listOf("url")) } + .ifEmpty { ToolJson.findFirstUrl(call.args, call.result) } + + return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "Navigated in browser" + } +} + +/** `browser_snapshot`: control-count summary of the accessibility snapshot. */ +internal object BrowserSnapshotRenderer : ToolRenderer { + private fun summarizeSnapshot(snapshot: String): String { + fun count(re: Regex): Int = re.findAll(snapshot).count() + + val stats = + listOf( + "${count(Regex("""button\s+"[^"]+"""))} buttons", + "${count(Regex("""link\s+"[^"]+"""))} links", + "${count(Regex("""(?:textbox|combobox|searchbox)\s+"[^"]+"""))} inputs", + ).joinToString(" · ") + + val labels = + Regex("""(?:button|link|combobox|textbox)\s+"([^"]+)"""") + .findAll(snapshot) + .map { it.groupValues[1].trim() } + .filter { it.isNotEmpty() } + .take(4) + .toList() + + return if (labels.isNotEmpty()) "$stats\nTop controls: ${labels.joinToString(", ")}" else stats + } + + override fun subtitle(call: ToolCall): String { + val snapshot = ToolJson.firstString(call.result, listOf("snapshot")) + + return if (snapshot.isNotEmpty()) { + summarizeSnapshot(snapshot) + } else { + "Captured a browser accessibility snapshot" + } + } + + override fun detail(call: ToolCall): String { + val snapshot = ToolJson.firstString(call.result, listOf("snapshot")) + + return if (snapshot.isNotEmpty()) { + summarizeSnapshot(snapshot) + } else { + ToolJson.fallbackDetailText(call.rawArgs, call.rawResult) + } + } +} + +/** `browser_click`: what got clicked, with internal refs called out. */ +internal object BrowserClickRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val clicked = + ToolJson + .firstString(call.result, listOf("clicked")) + .ifEmpty { ToolJson.firstString(call.args, listOf("ref", "target")) } + + return when { + clicked.isEmpty() -> "Clicked on page" + clicked.startsWith("@") -> "Clicked page element (internal ref $clicked)" + else -> "Clicked $clicked" + } + } +} + +/** `browser_fill` / `browser_type`: which field got which value. */ +internal object BrowserTypeRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val field = ToolJson.firstString(call.args, listOf("label", "field", "ref", "target")) + val value = ToolJson.firstString(call.args, listOf("value", "text")) + + return listOf( + field.takeIf { it.isNotEmpty() }?.let { "Field: $it" }, + value.takeIf { it.isNotEmpty() }?.let { "Value: ${ToolJson.compactPreview(it, 42)}" }, + ).filterNotNull() + .joinToString(" · ") + .ifEmpty { "Filled page input" } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ComputerUseRenderer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ComputerUseRenderer.kt new file mode 100644 index 00000000..ddad71b9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ComputerUseRenderer.kt @@ -0,0 +1,156 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import kotlinx.serialization.json.JsonArray + +/** + * `computer_use`: desktop-automation results — app rosters, interactable + * element inventories, multimodal text blocks, and plain action acks. + */ +internal object ComputerUseRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val args = call.args + val result = call.result + + val error = ToolJson.firstString(result, listOf("error")) + if (error.isNotEmpty()) { + return "❌ $error" + } + + val apps = result?.get("apps") as? JsonArray + if (apps != null) { + return "${apps.size} apps" + } + + val elements = result?.get("elements") as? JsonArray + if (elements != null) { + val mode = ToolJson.firstString(result, listOf("mode")) + val width = ToolJson.intValue(result["width"]) + val height = ToolJson.intValue(result["height"]) + val modePart = mode.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: "" + val sizePart = if (width != null && height != null) " ${width}x$height" else "" + return "${elements.size} elements$modePart$sizePart" + } + + val action = ToolJson.firstString(args, listOf("action")) + if (action.isNotEmpty()) { + val ok = ToolJson.boolTrue(result?.get("ok")) + val fail = ToolJson.boolFalse(result?.get("ok")) + return "$action${when { + ok -> " ✓" + fail -> " ✗" + else -> "" + }}" + } + + return "Computer use" + } + + override fun detail(call: ToolCall): String { + val args = call.args + val result = call.result + + val error = ToolJson.firstString(result, listOf("error")) + if (error.isNotEmpty()) { + return "❌ $error" + } + + val isMultimodal = ToolJson.boolTrue(result?.get("_multimodal")) + val content = result?.get("content") as? JsonArray + val multimodalText = + if (isMultimodal && content != null) { + content + .mapNotNull { el -> + val e = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + if (ToolJson.firstString(e, listOf("type")) == "text") { + ToolJson.firstString(e, listOf("text")) + } else { + null + } + }.joinToString("\n") + } else { + "" + } + + val textSummary = ToolJson.firstString(result, listOf("summary")) + val visionAnalysis = ToolJson.firstString(result, listOf("vision_analysis")) + val apps = result?.get("apps") as? JsonArray + val elements = result?.get("elements") as? JsonArray + val mode = ToolJson.firstString(result, listOf("mode")) + val width = ToolJson.intValue(result?.get("width")) + val height = ToolJson.intValue(result?.get("height")) + val action = ToolJson.firstString(args, listOf("action")) + val ok = ToolJson.boolTrue(result?.get("ok")) + val fail = ToolJson.boolFalse(result?.get("ok")) + val message = ToolJson.firstString(result, listOf("message")) + + val body = + buildString { + val textPart = + multimodalText + .ifEmpty { + ToolJson.firstString( + result, + listOf("text_summary"), + ) + }.ifEmpty { textSummary } + if (textPart.isNotEmpty()) { + append(textPart) + if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) { + append("\n\n$textSummary") + } + } + + if (apps != null) { + if (isNotEmpty()) append("\n\n") + apps.forEachIndexed { idx, el -> + val obj = ToolJson.parseMaybeObject(el) + val name = ToolJson.firstString(obj, listOf("name")).ifEmpty { "?" } + val pid = ToolJson.intValue(obj?.get("pid")) + append("${idx + 1}. $name") + if (pid != null) append(" (PID: $pid)") + append("\n") + } + } else if (elements != null) { + if (isNotEmpty()) append("\n\n") + append("🖥️ ${elements.size} interactable elements") + if (mode.isNotEmpty()) append(" (mode: $mode)") + if (width != null && height != null) append(" • ${width}x$height") + if (textSummary.isNotEmpty()) append("\n\n$textSummary") + val previewCount = minOf(elements.size, 5) + if (previewCount > 0) { + append("\n\n") + for (i in 0 until previewCount) { + val obj = ToolJson.parseMaybeObject(elements[i]) + val idx = ToolJson.intValue(obj?.get("index")) + val role = ToolJson.firstString(obj, listOf("role")) + val label = ToolJson.firstString(obj, listOf("label")) + append(" #$idx ${role}${label.takeIf { it.isNotEmpty() }?.let { " '$it'" } ?: ""}\n") + } + if (elements.size > previewCount) { + append(" ... and ${elements.size - previewCount} more") + } + } + } else if (action.isNotEmpty() && multimodalText.isEmpty() && + ToolJson + .firstString( + result, + listOf("text_summary"), + ).isEmpty() && textSummary.isEmpty() + ) { + append("$action:") + when { + ok -> append(" ✅ ok") + fail -> append(" ❌ failed") + } + if (message.isNotEmpty()) append("\n$message") + } else if (isEmpty()) { + append("Computer use action") + } + } + + return if (visionAnalysis.isNotEmpty()) "$body\n\n━━━ Vision Analysis ━━━\n$visionAnalysis" else body + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt new file mode 100644 index 00000000..eb8501c2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt @@ -0,0 +1,208 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.DiffStats +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras +import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** Path/diff plumbing shared by the file-oriented renderers. */ +internal object FileEditSupport { + private val HTML_PATH_REGEX = + Regex( + "(?:^|\\s)(?:[ab]/)?([^\\s]+\\.html?)(?=\\s|$)", + RegexOption.IGNORE_CASE, + ) + + fun basename(path: String): String { + val normalized = path.replace('\\', '/').trim() + + return normalized.split("/").filter { it.isNotEmpty() }.lastOrNull() ?: normalized + } + + fun editPath( + args: JsonObject?, + result: JsonObject?, + ): String { + val fromArgs = ToolJson.firstString(args, listOf("path", "file", "filepath")) + if (fromArgs.isNotEmpty()) { + return fromArgs + } + + val fromResult = ToolJson.firstString(result, listOf("path", "file", "filepath", "resolved_path")) + if (fromResult.isNotEmpty()) { + return fromResult + } + + return htmlPathFromInlineDiff(ToolJson.firstString(result, listOf("inline_diff", "diff"))) + } + + private fun stripInlineDiffChrome(value: String): String = + ToolJson + .stripAnsi(value) + .replace(Regex("^\\s*┊\\s*review diff\\s*\\n", RegexOption.IGNORE_CASE), "") + .trim() + + fun inlineDiffFromResult(result: JsonElement?): String { + val record = ToolJson.parseMaybeObject(result) ?: return "" + + for (key in listOf("inline_diff", "diff")) { + val value = record[key] + + if (value is JsonPrimitive && value.isString && value.content.trim().isNotEmpty()) { + return stripInlineDiffChrome(value.content) + } + } + + return "" + } + + private fun htmlPathFromInlineDiff(value: String): String { + val cleaned = stripInlineDiffChrome(value) + + for (match in HTML_PATH_REGEX.findAll(cleaned)) { + val candidate = match.groupValues[1].trim() + + if (candidate.isNotEmpty()) { + return candidate + } + } + + return "" + } + + fun countDiffLineStats(diff: String): DiffStats { + var added = 0 + var removed = 0 + + for (line in diff.split("\n")) { + when { + line.startsWith("+") && !line.startsWith("+++") -> added += 1 + line.startsWith("-") && !line.startsWith("---") -> removed += 1 + } + } + + return DiffStats(added = added, removed = removed) + } + + /** Subtitle shared by read_file and the edit family: the path, or a summary. */ + fun pathSubtitle( + call: ToolCall, + path: String, + ): String = path.ifEmpty { ToolJson.fallbackDetailText(call.args, call.rawResult) } +} + +/** `read_file`: "Read name.kt L10-14" title with the file content as detail. */ +internal object ReadFileRenderer : ToolRenderer { + private fun displayTarget( + args: JsonObject?, + result: JsonObject?, + ): String { + val inherited = ToolJson.firstString(args, listOf("context", "preview")) + if (inherited.isNotEmpty()) { + return inherited + } + + val path = ToolJson.firstString(args, listOf("path", "file", "filepath")) + if (path.isEmpty()) { + return "" + } + + val offset = ToolJson.intValue(args?.get("offset")) + val limit = ToolJson.intValue(args?.get("limit")) + var lineLabel = "" + + if (offset != null) { + lineLabel = if (limit == null || limit <= 1) "L$offset" else "L$offset-${offset + limit - 1}" + } else if (limit != null) { + val content = ToolJson.firstString(result, listOf("content")) + val lines = + content + .split("\n") + .mapNotNull { line -> + Regex("^(\\d+)\\|") + .find(line) + ?.groupValues + ?.get(1) + ?.toIntOrNull() + } + + if (lines.isNotEmpty()) { + lineLabel = + if (lines.first() == lines.last()) { + "L${lines.first()}" + } else { + "L${lines.first()}-${lines.last()}" + } + } + } + + return listOf(FileEditSupport.basename(path), lineLabel).filter { it.isNotEmpty() }.joinToString(" ") + } + + override fun pendingTitle(call: ToolCall): String = "Reading ${displayTarget(call.args, null)}" + + override fun doneTitle(call: ToolCall): String = "Read ${displayTarget(call.args, call.result)}" + + override fun subtitle(call: ToolCall): String = + FileEditSupport.pathSubtitle(call, ToolJson.firstString(call.args, listOf("path", "file", "filepath"))) + + override fun detail(call: ToolCall): String { + if (call.rawResult == null) { + return "" + } + + return ToolJson.firstString(call.result, listOf("content", "text", "data", "body")) + } +} + +/** + * `edit_file` / `patch` / `write_file`: basename title, path subtitle, and + * the inline diff (with +/− stats) as the primary payload. Text detail only + * appears when there is no diff to render. + */ +internal object FileEditRenderer : ToolRenderer { + override fun doneTitle(call: ToolCall): String? { + val path = FileEditSupport.editPath(call.args, call.result) + + return if (path.isNotEmpty()) FileEditSupport.basename(path) else null + } + + override fun subtitle(call: ToolCall): String = + FileEditSupport.pathSubtitle(call, FileEditSupport.editPath(call.args, call.result)) + + override fun detail(call: ToolCall): String { + if (FileEditSupport.inlineDiffFromResult(call.rawResult).isNotEmpty()) { + return "" + } + + return ToolJson.firstString(call.result, listOf("message", "summary")).ifEmpty { + if (FileEditSupport.editPath(call.args, call.result).isNotEmpty()) { + "" + } else { + ToolJson.fallbackDetailText(call.rawArgs, call.rawResult) + } + } + } + + override fun extras( + call: ToolCall, + status: ToolViewStatus, + ): ToolViewExtras { + val inlineDiff = FileEditSupport.inlineDiffFromResult(call.rawResult) + + if (inlineDiff.isEmpty()) { + return ToolViewExtras.NONE + } + + return ToolViewExtras( + inlineDiff = inlineDiff, + diffPath = FileEditSupport.editPath(call.args, call.result), + diffStats = FileEditSupport.countDiffLineStats(inlineDiff), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt new file mode 100644 index 00000000..faece561 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt @@ -0,0 +1,57 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer + +/** `image_generate`: generated-image URL with the prompt in the detail. */ +internal object ImageGenerateRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val imageUrl = + ToolJson + .firstString(call.result, listOf("image")) + .ifEmpty { ToolJson.firstString(call.args, listOf("image_url")) } + val modality = ToolJson.firstString(call.result, listOf("modality")) + + return listOf(imageUrl.take(60), modality.takeIf { it.isNotEmpty() }?.let { "($it)" }) + .filterNotNull() + .joinToString(" ") + } + + override fun detail(call: ToolCall): String { + val error = ToolJson.firstString(call.result, listOf("error")) + val imageUrl = ToolJson.firstString(call.result, listOf("image")) + val prompt = ToolJson.firstString(call.args, listOf("prompt")) + + return buildString { + if (error.isNotEmpty()) { + append("❌ $error") + if (imageUrl.isNotEmpty()) append("\n🔗 $imageUrl") + } else if (imageUrl.isNotEmpty()) { + append("🖼️ ") + if (prompt.isNotEmpty()) append("$prompt\n") + append("\n🔗 $imageUrl") + } else { + append("✅ Generated") + } + } + } +} + +/** `vision_analyze`: the analyzed image URL and the model's description. */ +internal object VisionAnalyzeRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.args, listOf("image_url")).take(60) + + override fun detail(call: ToolCall): String { + val error = ToolJson.firstString(call.result, listOf("error")) + val description = ToolJson.firstString(call.result, listOf("description")) + val content = ToolJson.firstString(call.result, listOf("content")) + + return when { + error.isNotEmpty() -> "❌ $error" + description.isNotEmpty() -> description + content.isNotEmpty() -> content + else -> "No description available" + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MemoryRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MemoryRenderers.kt new file mode 100644 index 00000000..fc3fcaef --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MemoryRenderers.kt @@ -0,0 +1,92 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import kotlinx.serialization.json.JsonArray + +/** `memory`: action-verb titles that keep the write target visible. */ +internal object MemoryRenderer : ToolRenderer { + override fun pendingTitle(call: ToolCall): String = "Saving" + + override fun doneTitle(call: ToolCall): String { + val action = ToolJson.firstString(call.args, listOf("action")).lowercase() + val target = ToolJson.firstString(call.args, listOf("target")) + val base = + when (action) { + "replace", "update" -> "Memory Updated" + "remove", "delete" -> "Memory Removed" + "add" -> "Memory Saved" + else -> "Memory ${ToolJson.firstString(call.args, listOf("action"))}" + } + + // Keep the target visible — "Memory Saved (user)" — like the + // pre-engine mobile summary did. + return if (target.isNotEmpty()) "$base ($target)" else base + } + + override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.result, listOf("message", "error")) + + override fun detail(call: ToolCall): String = ToolJson.firstString(call.result, listOf("message", "error")) +} + +/** `fact_store`: add/remove/update acks and fact listings with trust scores. */ +internal object FactStoreRenderer : ToolRenderer { + private fun facts(call: ToolCall): JsonArray? = + (call.result?.get("results") as? JsonArray) + ?: (call.result?.get("facts") as? JsonArray) + + override fun subtitle(call: ToolCall): String { + val args = call.args + val result = call.result + val action = ToolJson.firstString(args, listOf("action")).ifEmpty { ToolJson.firstString(result, listOf("action")) } + val entity = ToolJson.firstString(args, listOf("entity")) + val query = ToolJson.firstString(args, listOf("query")) + val facts = facts(call) + val count = ToolJson.intValue(result?.get("count")) ?: facts?.size ?: 0 + val status = ToolJson.firstString(result, listOf("status")) + val factId = ToolJson.intValue(result?.get("fact_id")) + val removed = ToolJson.boolTrue(result?.get("removed")) + val updated = ToolJson.boolTrue(result?.get("updated")) + + val actionContext = + when { + entity.isNotEmpty() -> " ($action: $entity)" + query.isNotEmpty() -> " ($action: $query)" + else -> " ($action)" + } + + return when { + status == "added" && factId != null -> "Fact added (ID: $factId)" + status == "added" -> "Fact added" + removed -> "Fact removed" + updated -> "Fact updated" + facts != null -> "$count facts$actionContext" + else -> action + } + } + + override fun detail(call: ToolCall): String = + facts(call) + ?.mapNotNull { el -> + val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val fid = ToolJson.intValue(item["fact_id"]) ?: 0 + val content = ToolJson.firstString(item, listOf("content")) + val category = ToolJson.firstString(item, listOf("category")) + val trust = ToolJson.numberValue(item["trust_score"]) + val tags = ToolJson.firstString(item, listOf("tags")) + + val metaParts = mutableListOf() + if (category.isNotEmpty()) metaParts += "[$category]" + if (trust != null) metaParts += "trust: ${"%.2f".format(trust)}" + if (tags.isNotEmpty()) metaParts += "🏷️ $tags" + + if (metaParts.isNotEmpty()) { + "#$fid $content\n ${metaParts.joinToString(" ")}" + } else { + "#$fid $content" + } + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } + ?: "" +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ProjectRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ProjectRenderers.kt new file mode 100644 index 00000000..30508114 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ProjectRenderers.kt @@ -0,0 +1,68 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive + +/** `project_list`: project roster with the active project starred. */ +internal object ProjectListRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val projects = (call.result?.get("projects") as? JsonArray)?.size + val active = ToolJson.firstString(call.result, listOf("active_id")) + + return if (projects != null) { + "$projects projects${active.takeIf { it.isNotEmpty() }?.let { " (1 active)" } ?: ""}" + } else { + "Projects" + } + } + + override fun detail(call: ToolCall): String = + (call.result?.get("projects") as? JsonArray) + ?.mapNotNull { el -> + val p = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val name = ToolJson.firstString(p, listOf("name")) + val slug = ToolJson.firstString(p, listOf("slug")) + val path = ToolJson.firstString(p, listOf("primary_path")) + val isActive = + (p["active"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true + val lines = mutableListOf("${if (isActive) "⭐ " else "📁 "}$name") + if (slug.isNotEmpty()) lines += " 🏷️ $slug" + if (path.isNotEmpty()) lines += " 📍 $path" + if (isActive) lines += " ✅ ACTIVE PROJECT" + lines.joinToString("\n") + }?.joinToString("\n\n") + ?: "" +} + +/** `project_create` / `project_switch`: mutation ack with name/slug/path. */ +internal object ProjectMutateRenderer : ToolRenderer { + private fun actionLabel(call: ToolCall): String = if (call.name == "project_create") "Created" else "Switched to" + + override fun subtitle(call: ToolCall): String { + val name = ToolJson.firstString(call.result, listOf("name")) + val label = actionLabel(call) + + return if (name.isNotEmpty()) "$label $name" else label + } + + override fun detail(call: ToolCall): String { + val error = ToolJson.firstString(call.result, listOf("error")) + val name = ToolJson.firstString(call.result, listOf("name")) + val slug = ToolJson.firstString(call.result, listOf("slug")) + val path = ToolJson.firstString(call.result, listOf("primary_path")) + + return buildString { + if (error.isNotEmpty()) { + append("❌ $error") + } else { + append("✅ ${actionLabel(call)}") + if (name.isNotEmpty()) append(": $name") + if (slug.isNotEmpty()) append("\n 🏷️ $slug") + if (path.isNotEmpty()) append("\n 📍 $path") + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt new file mode 100644 index 00000000..2366a69e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt @@ -0,0 +1,140 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** `skills_list`: count-with-category subtitle and a name/description list. */ +internal object SkillsListRenderer : ToolRenderer { + private fun skills(call: ToolCall): JsonArray? = + (call.result?.get("skills") as? JsonArray) + ?: (call.result?.get("results") as? JsonArray) + + override fun subtitle(call: ToolCall): String { + val category = ToolJson.firstString(call.args, listOf("category")) + val count = skills(call)?.size ?: 0 + + return if (count > 0) { + "$count skills${category.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: ""}" + } else { + "No skills found" + } + } + + override fun detail(call: ToolCall): String = + skills(call) + ?.mapNotNull { el -> + val s = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val name = ToolJson.firstString(s, listOf("name")) + val desc = ToolJson.firstString(s, listOf("description")) + val cat = ToolJson.firstString(s, listOf("category")) + val lines = mutableListOf("📌 $name") + if (desc.isNotEmpty()) lines += " $desc" + if (cat.isNotEmpty()) lines += " [$cat]" + lines.joinToString("\n") + }?.joinToString("\n\n") + ?: "No skills found" +} + +/** `skill_view`: content preview capped at 500 chars plus linked files. */ +internal object SkillViewRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.args, listOf("name")) + + override fun detail(call: ToolCall): String { + val content = ToolJson.firstString(call.result, listOf("content")) + val linkedFiles = call.result?.get("linked_files") as? JsonObject + val contentPreview = + if (content.length > 500) { + "${content.take( + 500, + )}\n... [${content.length - 500} more chars]" + } else { + content + } + val filesInfo = + linkedFiles + ?.entries + ?.joinToString("\n") { (k, v) -> + val paths = + when (v) { + is JsonArray -> { + v.joinToString( + ", ", + ) { (it as? JsonPrimitive)?.content ?: it.toString() } + } + + is JsonPrimitive -> { + v.content + } + + else -> { + v.toString() + } + } + " 📎 $k: $paths" + } + + return buildString { + if (contentPreview.isNotEmpty()) append(contentPreview) + if (filesInfo != null) { + if (contentPreview.isNotEmpty()) append("\n\n") + append("━━━ Linked Files ━━━\n$filesInfo") + } + if (content.isEmpty() && linkedFiles == null) append("No content available") + } + } +} + +/** `skill_manage`: action ack with the skill name. */ +internal object SkillManageRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val action = ToolJson.firstString(call.args, listOf("action")) + val name = ToolJson.firstString(call.args, listOf("name")) + + return if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action" + } + + override fun detail(call: ToolCall): String { + val error = ToolJson.firstString(call.result, listOf("error")) + if (error.isNotEmpty()) { + return "❌ $error" + } + + val msg = ToolJson.firstString(call.result, listOf("message")) + val failed = ToolJson.boolFalse(call.result?.get("success")) + + // Pinned quirk from the original engine: the branch order is inverted + // (a non-false `success` renders the failure line). Preserved verbatim + // by the characterization suite; fix upstream deliberately if desired. + return when { + !failed -> "❌ Skill operation failed" + msg.isNotEmpty() -> "✅ $msg" + else -> "✅ Skill operation done" + } + } +} + +/** `tool_search`: on-demand tool discovery matches. */ +internal object ToolSearchRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val query = ToolJson.firstString(call.args, listOf("query")) + val matches = (call.result?.get("matches") as? JsonArray)?.size + + return if (matches != null) "$query ($matches matches)" else query + } + + override fun detail(call: ToolCall): String = + (call.result?.get("matches") as? JsonArray) + ?.mapNotNull { el -> + val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val name = ToolJson.firstString(m, listOf("name")) + val desc = ToolJson.firstString(m, listOf("description")) + val lines = mutableListOf("🔧 $name") + if (desc.isNotEmpty()) lines += " $desc" + lines.joinToString("\n") + }?.joinToString("\n\n") + ?: "No matching tools" +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt new file mode 100644 index 00000000..e8311ea7 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt @@ -0,0 +1,265 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** `cronjob`: job listings with schedules, or the mutated job's key fields. */ +internal object CronjobRenderer : ToolRenderer { + override fun doneTitle(call: ToolCall): String = + "Cron ${ToolJson.firstString(call.args, listOf("action")).ifEmpty { "manage" }}" + + override fun subtitle(call: ToolCall): String { + val jobs = call.result?.get("jobs") as? JsonArray + + if (jobs != null) { + return if (jobs.isNotEmpty()) { + "${jobs.size} cron ${if (jobs.size == 1) "job" else "jobs"}" + } else { + "No cron jobs" + } + } + + val message = ToolJson.firstString(call.result, listOf("message")) + if (message.isNotEmpty()) { + return message + } + + val action = ToolJson.firstString(call.args, listOf("action")).ifEmpty { "manage" } + val name = + ToolJson + .firstString(call.result, listOf("name")) + .ifEmpty { ToolJson.firstString(call.args, listOf("name", "job_id")) } + val label = action.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + + return if (name.isNotEmpty()) "$label $name" else "Cron $action" + } + + override fun detail(call: ToolCall): String { + val result = call.result + val jobs = result?.get("jobs") as? JsonArray + + if (jobs != null) { + if (jobs.isEmpty()) { + return "No cron jobs scheduled" + } + + return jobs + .take(20) + .mapNotNull { job -> + val row = ToolJson.parseMaybeObject(job) ?: return@mapNotNull null + val name = ToolJson.firstString(row, listOf("name", "id")).ifEmpty { "job" } + val sched = ToolJson.firstString(row, listOf("schedule_display", "schedule")) + if (sched.isNotEmpty()) "- $name · $sched" else "- $name" + }.joinToString("\n") + } + + val rows = + listOf( + "Schedule" to ToolJson.firstString(result, listOf("schedule")), + "Repeat" to ToolJson.firstString(result, listOf("repeat")), + "Delivery" to ToolJson.firstString(result, listOf("deliver")), + "Next run" to ToolJson.firstString(result, listOf("next_run_at")), + ).filter { it.second.isNotEmpty() } + + return if (rows.isNotEmpty()) { + rows.joinToString( + "\n", + ) { (k, v) -> "$k: $v" } + } else { + ToolJson.fallbackDetailText(call.args, result) + } + } +} + +/** `todo`: status-count subtitle plus a checklist with [x]/[>]/[~] markers. */ +internal object TodoRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val summary = call.result?.get("summary") as? JsonObject ?: return "" + val total = ToolJson.intValue(summary["total"]) ?: 0 + val pending = ToolJson.intValue(summary["pending"]) ?: 0 + val inProgress = ToolJson.intValue(summary["in_progress"]) ?: 0 + val completed = ToolJson.intValue(summary["completed"]) ?: 0 + val cancelled = ToolJson.intValue(summary["cancelled"]) ?: 0 + + val parts = + listOf( + pending.takeIf { it > 0 }?.let { "$it pending" }, + inProgress.takeIf { it > 0 }?.let { "$it in_progress" }, + completed.takeIf { it > 0 }?.let { "$it completed" }, + cancelled.takeIf { it > 0 }?.let { "$it cancelled" }, + ).filterNotNull() + + val itemWord = if (total == 1) "item" else "items" + + return if (parts.isEmpty()) "0 items" else "$total $itemWord (${parts.joinToString(", ")})" + } + + override fun detail(call: ToolCall): String { + val todos = call.result?.get("todos") as? JsonArray ?: return "" + + return todos + .mapNotNull { el -> + val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val id = ToolJson.firstString(item, listOf("id")) + val content = ToolJson.firstString(item, listOf("content")) + val status = ToolJson.firstString(item, listOf("status")).ifEmpty { "pending" } + val parent = ToolJson.firstString(item, listOf("parent")) + val marker = + when (status) { + "completed" -> "[x]" + "in_progress" -> "[>]" + "cancelled" -> "[~]" + else -> "[ ]" + } + val indent = if (parent.isNotEmpty()) " ↳ " else "" + "$indent$marker $id. $content" + }.joinToString("\n") + } +} + +/** `session_search`: mode-specific subtitles and rich session/message lists. */ +internal object SessionSearchRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val result = call.result + val mode = ToolJson.firstString(result, listOf("mode")) + val query = ToolJson.firstString(result, listOf("query")) + val count = ToolJson.intValue(result?.get("count")) + val msgCount = ToolJson.intValue(result?.get("message_count")) + val messages = result?.get("messages") as? JsonArray + val truncated = ToolJson.boolTrue(result?.get("truncated")) + + return when (mode) { + "discover" -> "${count ?: 0} session${if (count == 1) "" else "s"}${query.takeIf { it.isNotEmpty() }?.let { + ": ${it.take( + 60, + )}" + } ?: ""}" + + "scroll" -> "${messages?.size ?: 0} messages (scroll)" + + "read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}" + + "browse" -> "${count ?: 0} recent sessions" + + else -> "session_search" + } + } + + override fun detail(call: ToolCall): String { + val result = call.result + val results = result?.get("results") as? JsonArray + val messages = result?.get("messages") as? JsonArray + + val formattedResults = + results + ?.mapIndexedNotNull { idx, el -> + val item = ToolJson.parseMaybeObject(el) ?: return@mapIndexedNotNull null + val whenField = ToolJson.firstString(item, listOf("when", "started_at")) + val source = ToolJson.firstString(item, listOf("source")) + val title = ToolJson.firstString(item, listOf("title")) + val snippet = + ToolJson + .firstString(item, listOf("snippet")) + .ifEmpty { ToolJson.firstString(item, listOf("preview")) } + val model = ToolJson.firstString(item, listOf("model")) + val matchedRole = ToolJson.firstString(item, listOf("matched_role")) + val sessionMsgCount = ToolJson.intValue(item["message_count"]) + + val header = whenField.takeIf { it.isNotEmpty() }?.let { "📅 $it" } ?: "" + val sourceTag = source.takeIf { it.isNotEmpty() }?.let { "[$it]" } ?: "" + val titleLine = title.takeIf { it.isNotEmpty() }?.let { "\n 📄 $it" } ?: "" + val modelLine = model.takeIf { it.isNotEmpty() }?.let { "\n 🤖 $it" } ?: "" + val matchedLine = matchedRole.takeIf { it.isNotEmpty() }?.let { "\n 🎯 matched: $it" } ?: "" + val countLine = sessionMsgCount?.let { "\n $it msgs" } ?: "" + val snippetLine = + snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: "" + + "━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine" + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } + + val anchorId = ToolJson.intValue(result?.get("around_message_id")) + + val formattedMessages = + messages + ?.mapNotNull { el -> + val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val msgId = ToolJson.intValue(item["id"]) + val role = ToolJson.firstString(item, listOf("role")).ifEmpty { "?" } + val content = ToolJson.firstString(item, listOf("content")) + val toolName = ToolJson.firstString(item, listOf("tool_name")) + + val roleEmoji = + when (role) { + "user" -> "👤" + "assistant" -> "🤖" + "tool" -> "🔧" + else -> "❓" + } + val namePart = if (toolName.isNotEmpty()) " ($toolName)" else "" + val anchor = if (anchorId != null && msgId == anchorId) " ⬅️" else "" + val cleanContent = content.take(300).replace("\n", " ") + + "[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent" + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } + + return formattedResults ?: formattedMessages ?: "" + } +} + +/** `process`: background session listings and lifecycle acks. */ +internal object ProcessRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val action = ToolJson.firstString(call.args, listOf("action")) + val procId = ToolJson.firstString(call.args, listOf("session_id")) + + return if (procId.isNotEmpty()) "$action: $procId" else action + } + + override fun detail(call: ToolCall): String { + val result = call.result + val error = ToolJson.firstString(result, listOf("error")) + if (error.isNotEmpty()) { + return "❌ $error" + } + + val processes = result?.get("processes") as? JsonArray + if (processes != null) { + return processes + .mapNotNull { el -> + val p = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val pid = + ToolJson + .firstString(p, listOf("session_id")) + .ifEmpty { ToolJson.firstString(p, listOf("id")) } + val pStatus = ToolJson.firstString(p, listOf("status")) + val cmd = ToolJson.firstString(p, listOf("command")) + val running = + (p["running"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } + val parts = mutableListOf("📌 $pid${cmd.takeIf { it.isNotEmpty() }?.let { ": $it" } ?: ""}") + if (pStatus.isNotEmpty()) parts += " Status: $pStatus" + if (running != null) parts += " Running: $running" + parts.joinToString("\n") + }.joinToString("\n\n") + } + + val output = ToolJson.firstString(result, listOf("output")) + if (output.isNotEmpty()) { + val status = ToolJson.firstString(result, listOf("status")) + return "${status.takeIf { it.isNotEmpty() }?.let { "Status: $it\n" } ?: ""}$output" + } + + val status = ToolJson.firstString(result, listOf("status")) + if (status.isNotEmpty()) { + return "✅ $status" + } + + val action = ToolJson.firstString(call.args, listOf("action")) + return "✅ ${action.ifEmpty { "done" }} done" + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TerminalRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TerminalRenderers.kt new file mode 100644 index 00000000..3fe81789 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TerminalRenderers.kt @@ -0,0 +1,150 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.CommandSummarizer +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras +import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * `terminal` and `execute_code`: shell-styled rows with a summarized command + * title, first output line as subtitle, and split (or merged) streams for + * the ANSI-aware expanded view. Exit code and the `$` command line are only + * meaningful for `terminal`. + */ +internal object TerminalRenderer : ToolRenderer { + private fun shellCommand(args: JsonObject?): String = + ToolJson + .firstString(args, listOf("command", "code")) + .ifEmpty { ToolJson.firstString(args, listOf("context", "preview")) } + .ifEmpty { ToolJson.contextValue(args) } + + private fun shellOutput(result: JsonObject?): String { + val output = ToolJson.firstString(result, listOf("output", "stdout", "stderr")) + val lines = + (result?.get("lines") as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content } + ?.joinToString("\n") + ?: "" + + return listOf(output, lines).filter { it.isNotEmpty() }.joinToString("\n") + } + + private fun commandTitle( + call: ToolCall, + runningVerb: Boolean, + ): String? { + val command = shellCommand(call.args) + val verb = + when { + call.name == "execute_code" && runningVerb -> "Running code" + call.name == "execute_code" -> "Ran code" + runningVerb -> "Running" + else -> "Ran" + } + + if (command.isEmpty()) { + return null + } + + return "$verb ${ToolJson.compactPreview(CommandSummarizer.summarizeShellCommand(command), 160)}" + } + + override fun pendingTitle(call: ToolCall): String? = commandTitle(call, runningVerb = true) + + override fun doneTitle(call: ToolCall): String? = commandTitle(call, runningVerb = false) + + override fun subtitle(call: ToolCall): String { + val firstMeaningfulLine = + shellOutput(call.result) + .split("\n") + .map { it.trim() } + .firstOrNull { it.isNotEmpty() } + + // A terminal row with no output shows its command in the title; + // nothing more to add. + return firstMeaningfulLine?.let { ToolJson.compactPreview(it, 160) } ?: "" + } + + override fun detail(call: ToolCall): String { + val output = shellOutput(call.result) + + return when { + output.isNotEmpty() -> output + call.name == "execute_code" -> ToolJson.fallbackDetailText(call.rawArgs, call.rawResult) + // A terminal row with no output already shows its command in the + // title; the generic fallback would repeat it. + else -> "" + } + } + + override fun extras( + call: ToolCall, + status: ToolViewStatus, + ): ToolViewExtras { + // The gateway's terminal_tool emits a single merged `output` string + // (stdout+stderr, ANSI-stripped); older payloads carried separate + // `stdout`/`stderr`. Prefer a real stream split, fall back to the + // merged output as the single renderable stream. + val stdout = ToolJson.firstString(call.result, listOf("stdout", "output")) + val stderr = ToolJson.firstString(call.result, listOf("stderr")) + val hasSplitStreams = stdout.isNotEmpty() || stderr.isNotEmpty() + + return ToolViewExtras( + stdout = if (hasSplitStreams) stdout else null, + stderr = if (hasSplitStreams) stderr else null, + exitCode = if (call.name == "terminal") ToolJson.intValue(call.result?.get("exit_code")) else null, + terminalCommand = if (call.name == "terminal") shellCommand(call.args) else null, + ) + } +} + +/** `read_terminal`: a line-window over a live terminal session. */ +internal object ReadTerminalRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val result = call.result + val error = ToolJson.firstString(result, listOf("error")) + if (error.isNotEmpty()) { + return "❌ $error" + } + + val total = ToolJson.intValue(result?.get("total_lines")) ?: 0 + val start = ToolJson.intValue(result?.get("start")) ?: 0 + val end = ToolJson.intValue(result?.get("end")) ?: 0 + val text = ToolJson.firstString(result, listOf("text")) + + if (text.isEmpty()) { + return "Terminal output" + } + + val cursor = ToolJson.intValue(result?.get("cursor_row")) + + return "Lines $start-$end of $total${cursor?.let { ", cursor at row $it" } ?: ""}" + } + + override fun detail(call: ToolCall): String { + val result = call.result + val error = ToolJson.firstString(result, listOf("error")) + val text = ToolJson.firstString(result, listOf("text")) + val total = ToolJson.intValue(result?.get("total_lines")) ?: 0 + val start = ToolJson.intValue(result?.get("start")) ?: 0 + val end = ToolJson.intValue(result?.get("end")) ?: 0 + val cursor = ToolJson.intValue(result?.get("cursor_row")) + + return buildString { + if (error.isNotEmpty()) { + append("❌ $error") + } else if (text.isNotEmpty()) { + append("━━━ Terminal ━━━ ($start:$end / $total)") + if (cursor != null) append(" │ cursor row $cursor") + append("\n$text") + } else { + append("No terminal output") + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/WebRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/WebRenderers.kt new file mode 100644 index 00000000..9472f39a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/WebRenderers.kt @@ -0,0 +1,154 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.SearchHit +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras +import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** Resolve the URL a web/browser tool targeted, as a display hostname+path. */ +internal fun targetHostname( + args: JsonObject?, + result: JsonObject?, +): String { + val url = + ToolJson + .firstString(args, listOf("url", "target")) + .ifEmpty { ToolJson.firstString(result, listOf("url")) } + .ifEmpty { ToolJson.findFirstUrl(args, result) } + + return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "page" +} + +/** + * `web_search`: quoted-query title, parsed [SearchHit] rows for the + * structured result list, and a "Search results" section label. + */ +internal object WebSearchRenderer : ToolRenderer { + private fun searchQueryFor(args: JsonObject?): String = + ToolJson.firstString(args, listOf("search_term", "query")).ifEmpty { ToolJson.contextValue(args) } + + private fun extractSearchResults( + result: JsonElement?, + limit: Int = 6, + ): List = + ToolJson + .collectResultItems(result) + .mapNotNull { item -> + val r = ToolJson.parseMaybeObject(item) ?: return@mapNotNull null + + SearchHit( + title = ToolJson.cleanVisibleText(ToolJson.firstString(r, listOf("title", "name"))), + url = ToolJson.firstString(r, listOf("url", "href", "link")), + snippet = ToolJson.cleanVisibleText(ToolJson.firstString(r, listOf("snippet", "description", "body"))), + ) + }.filter { it.title.isNotEmpty() || it.url.isNotEmpty() } + .take(limit) + + override fun pendingTitle(call: ToolCall): String = + "Searching \"${ToolJson.compactPreview(searchQueryFor(call.args), 48)}\"" + + override fun doneTitle(call: ToolCall): String = + "Searched \"${ToolJson.compactPreview(searchQueryFor(call.args), 48)}\"" + + override fun subtitle(call: ToolCall): String = + searchQueryFor(call.args).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources" + + override fun extras( + call: ToolCall, + status: ToolViewStatus, + ): ToolViewExtras { + val hits = if (status != ToolViewStatus.ERROR) extractSearchResults(call.rawResult) else emptyList() + + return ToolViewExtras( + detailLabel = if (hits.isNotEmpty()) "Search results" else null, + searchHits = hits.ifEmpty { null }, + searchQuery = searchQueryFor(call.args).ifEmpty { null }, + ) + } +} + +/** `web_extract`: hostname title/subtitle with the extracted text as detail. */ +internal object WebExtractRenderer : ToolRenderer { + override fun pendingTitle(call: ToolCall): String = "Reading ${targetHostname(call.args, null)}" + + override fun doneTitle(call: ToolCall): String = "Read ${targetHostname(call.args, call.result)}" + + override fun subtitle(call: ToolCall): String { + val url = + ToolJson + .firstString(call.args, listOf("url")) + .ifEmpty { ToolJson.firstString(call.result, listOf("url")) } + .ifEmpty { ToolJson.findFirstUrl(call.args, call.result) } + + return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "Fetched webpage" + } + + override fun detail(call: ToolCall): String { + val direct = + ToolJson.firstString(call.result, listOf("content", "text", "markdown", "body", "summary", "message")) + if (direct.isNotEmpty()) { + return direct.replace(Regex("\\s*in\\s+\\d+(?:\\.\\d+)?s\\s*$"), "").trim() + } + + val results = call.result?.get("results") as? JsonArray + if (results != null) { + return results + .mapNotNull { item -> + val row = ToolJson.parseMaybeObject(item) ?: return@mapNotNull null + ToolJson.firstString(row, listOf("content", "text", "markdown", "body")) + }.filter { it.isNotEmpty() } + .joinToString("\n\n---\n\n") + } + + return ToolJson.fallbackDetailText(call.rawArgs, call.rawResult) + } +} + +/** `x_search`: query subtitle plus answer text with numbered citations. */ +internal object XSearchRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val query = ToolJson.firstString(call.args, listOf("query")) + val degraded = ToolJson.boolTrue(call.result?.get("degraded")) + + return if (query.isNotEmpty()) "$query${if (degraded) " (no citations)" else ""}" else "Queried X" + } + + override fun detail(call: ToolCall): String { + val result = call.result + val error = ToolJson.firstString(result, listOf("error")) + val answer = ToolJson.firstString(result, listOf("answer")) + val citations = result?.get("citations") as? JsonArray + val degraded = ToolJson.boolTrue(result?.get("degraded")) + + return when { + error.isNotEmpty() -> { + "❌ $error" + } + + answer.isNotEmpty() -> { + val lines = mutableListOf(answer) + if (citations != null && citations.isNotEmpty()) { + lines += "\n━━━ Citations ━━━" + citations.forEachIndexed { idx, cit -> + val c = ToolJson.parseMaybeObject(cit) ?: return@forEachIndexed + val url = ToolJson.firstString(c, listOf("url")) + val title = ToolJson.firstString(c, listOf("title")) + lines += "${idx + 1}. ${title.ifEmpty { url.ifEmpty { "source" } }}" + if (title.isNotEmpty() && url.isNotEmpty()) lines += " $url" + } + } + if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge" + lines.joinToString("\n") + } + + else -> { + "No results" + } + } + } +} From 10f7e9fa3ccc03b60f8f81bf9e30e2affef395bf Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Thu, 10 Sep 2026 22:48:43 +0300 Subject: [PATCH 04/42] fix(tool): satisfy ktlint formatting --- .../hermescontrol/ui/chat/tool/render/MemoryRenderers.kt | 7 ++++++- .../hermescontrol/ui/chat/tool/render/TerminalRenderers.kt | 2 ++ .../m57/hermescontrol/ui/chat/tool/render/WebRenderers.kt | 5 ++++- .../ui/chat/tool/ToolViewCharacterizationTest.kt | 3 ++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MemoryRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MemoryRenderers.kt index fc3fcaef..11a176d6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MemoryRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MemoryRenderers.kt @@ -39,7 +39,12 @@ internal object FactStoreRenderer : ToolRenderer { override fun subtitle(call: ToolCall): String { val args = call.args val result = call.result - val action = ToolJson.firstString(args, listOf("action")).ifEmpty { ToolJson.firstString(result, listOf("action")) } + val action = + ToolJson + .firstString( + args, + listOf("action"), + ).ifEmpty { ToolJson.firstString(result, listOf("action")) } val entity = ToolJson.firstString(args, listOf("entity")) val query = ToolJson.firstString(args, listOf("query")) val facts = facts(call) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TerminalRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TerminalRenderers.kt index 3fe81789..97c392ad 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TerminalRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TerminalRenderers.kt @@ -75,7 +75,9 @@ internal object TerminalRenderer : ToolRenderer { return when { output.isNotEmpty() -> output + call.name == "execute_code" -> ToolJson.fallbackDetailText(call.rawArgs, call.rawResult) + // A terminal row with no output already shows its command in the // title; the generic fallback would repeat it. else -> "" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/WebRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/WebRenderers.kt index 9472f39a..0c041931 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/WebRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/WebRenderers.kt @@ -44,7 +44,10 @@ internal object WebSearchRenderer : ToolRenderer { SearchHit( title = ToolJson.cleanVisibleText(ToolJson.firstString(r, listOf("title", "name"))), url = ToolJson.firstString(r, listOf("url", "href", "link")), - snippet = ToolJson.cleanVisibleText(ToolJson.firstString(r, listOf("snippet", "description", "body"))), + snippet = + ToolJson.cleanVisibleText( + ToolJson.firstString(r, listOf("snippet", "description", "body")), + ), ) }.filter { it.title.isNotEmpty() || it.url.isNotEmpty() } .take(limit) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt index 95880999..b9c08aab 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt @@ -46,7 +46,8 @@ class ToolViewCharacterizationTest { @Test fun `terminal split streams keep stdout and stderr separate`() { - val view = build("terminal", """{"command":"make"}""", """{"stdout":"built","stderr":"warn: x","exit_code":0}""") + val view = + build("terminal", """{"command":"make"}""", """{"stdout":"built","stderr":"warn: x","exit_code":0}""") assertEquals("built", view.stdout) assertEquals("warn: x", view.stderr) From 54dbb99a3dda483ea3f174fb28ec724163314b9b Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 00:03:36 +0300 Subject: [PATCH 05/42] refactor(chat): extract approvals delegate with tests --- .../ui/chat/ChatApprovalsDelegate.kt | 192 ++++++++++++++++++ .../ui/chat/ChatApprovalsDelegateTest.kt | 169 +++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt new file mode 100644 index 00000000..99c3094c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt @@ -0,0 +1,192 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Owns approval rendering, deduplication, user response submission, and + * reconnect/resume pending approval replay. + * + * Extracted behavior-preservingly from [ChatViewModel]. + */ +class ChatApprovalsDelegate( + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher, + private val uiState: MutableStateFlow, + private val runtimeSessionId: () -> String?, + private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, + private val trackRequest: (id: String, method: String) -> Unit, + private val addSystemMessage: (text: String) -> Unit, +) { + fun handleApprovalRequest(event: WsEvent.ApprovalRequest) { + val description = event.description ?: event.command ?: "Unknown command" + val content = "**Approval Required**\n$description" + val msg = + ChatMessage( + role = MessageRole.SYSTEM, + content = content, + approvalInfo = + ApprovalInfo( + command = event.command, + description = event.description, + patternKeys = event.patternKeys, + requestId = event.requestId, + choices = event.choices, + allowPermanent = event.allowPermanent, + smartDenied = event.smartDenied, + ), + ) + uiState.update { state -> + state.copy( + messages = state.messages + msg, + isAgentTyping = false, + ) + } + // Desktop parity (`prompts.ts` receiveApprovalRequest): ack the render + // so the backend knows this client holds the prompt. Fire-and-forget. + val requestId = event.requestId + val sessionId = runtimeSessionId() ?: event.sessionId ?: uiState.value.currentSessionId + if (requestId != null && sessionId != null) { + scope.launch(ioDispatcher) { + runCatching { + wsSend( + WsMethods.APPROVAL_RECEIVED, + mapOf( + "session_id" to sessionId, + "request_id" to requestId, + ), + null, + ) + } + } + } + } + + fun replayPendingApproval(sessionId: String) { + val targetSessionId = runtimeSessionId() ?: sessionId + scope.launch(ioDispatcher) { + wsSend( + WsMethods.APPROVAL_PENDING, + mapOf("session_id" to targetSessionId), + ) { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) } + } + } + + fun handleApprovalPendingResult(result: Any?) { + @Suppress("UNCHECKED_CAST") + val approvals = (result as? Map<*, *>)?.get("approvals") as? List<*> + val first = approvals?.filterIsInstance>()?.firstOrNull() ?: return + val sessionId = runtimeSessionId() ?: uiState.value.currentSessionId + maybeSurfacePendingApproval(first, sessionId) + } + + fun handleApprovalRespondResult(result: Any?) { + val map = result as? Map<*, *> + val resolved = (map?.get("resolved") as? Number)?.toInt() ?: 0 + if (resolved > 0) { + addSystemMessage("Approval submitted") + } + } + + fun maybeSurfacePendingApproval( + map: Map<*, *>, + sessionId: String?, + ) { + val requestId = map["request_id"] as? String + val alreadyShown = + requestId != null && + uiState.value.messages.any { it.approvalInfo?.requestId == requestId } + if (!alreadyShown) { + handleApprovalRequest(parseApprovalMap(map, sessionId)) + } + } + + fun respondToApproval(action: String) { + val state = uiState.value + val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return + val sessionId = runtimeSessionId() ?: state.currentSessionId ?: return + // Desktop sends `once` for a single run; legacy mobile sent `approve` + // (any non-deny still unblocks, but stay on-spec going forward). + val choice = if (action == "approve") "once" else action + val requestId = approvalMsg.approvalInfo?.requestId + + // Clear buttons immediately + uiState.update { s -> + s.copy( + messages = + s.messages.map { + if (it.id == approvalMsg.id) { + it.copy(approvalInfo = null) + } else { + it + } + }, + ) + } + + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "choice" to choice, + "all" to false, + ) + if (requestId != null) params["request_id"] = requestId + wsSend( + WsMethods.APPROVAL_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) } + // The queue can hold more pendings — surface the next one. + replayPendingApproval(sessionId) + } + } +} + +/** + * Convert a backend approval map (snake_case, from `approval.pending` or + * `session.info` `pending_approval`) into a typed event. Mirrors + * [EventParser] defaults so replay and live paths agree. + */ +internal fun parseApprovalMap( + map: Map<*, *>, + sessionId: String?, +): WsEvent.ApprovalRequest { + @Suppress("UNCHECKED_CAST") + val patternKeys = (map["pattern_keys"] as? List<*>)?.filterIsInstance() + + @Suppress("UNCHECKED_CAST") + val rawChoices = (map["choices"] as? List<*>)?.filterIsInstance() + val allowPermanent = map["allow_permanent"] as? Boolean + val allowSession = map["allow_session"] as? Boolean + val smartDenied = map["smart_denied"] as? Boolean + val choices = + rawChoices ?: run { + if (smartDenied == true) { + listOf("once", "deny") + } else { + buildList { + add("once") + if (allowSession != false) { + add("session") + if (allowPermanent != false) add("always") + } + add("deny") + } + } + } + return WsEvent.ApprovalRequest( + command = map["command"] as? String, + description = map["description"] as? String, + patternKeys = patternKeys, + sessionId = sessionId, + requestId = map["request_id"] as? String, + choices = choices, + allowPermanent = allowPermanent, + smartDenied = smartDenied, + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt new file mode 100644 index 00000000..4f3d298d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt @@ -0,0 +1,169 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatApprovalsDelegateTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "storage-session-1")) + private var runtimeId: String? = null + private val sentMethods = mutableListOf() + private val sentParams = mutableListOf>() + private val trackedRequests = mutableListOf>() + private val systemMessages = mutableListOf() + + private val delegate = + ChatApprovalsDelegate( + scope = testScope, + ioDispatcher = testDispatcher, + uiState = uiState, + runtimeSessionId = { runtimeId }, + wsSend = { method, params, onSent -> + sentMethods.add(method) + sentParams.add(params) + onSent?.invoke("req-id-${sentMethods.size}") + }, + trackRequest = { id, method -> trackedRequests.add(id to method) }, + addSystemMessage = { text -> systemMessages.add(text) }, + ) + + @Test + fun parseApprovalMap_honorsSmartDeniedAndChoicePermutations() { + val denied = + parseApprovalMap( + mapOf("smart_denied" to true), + sessionId = "s1", + ) + assertEquals(listOf("once", "deny"), denied.choices) + + val full = + parseApprovalMap( + mapOf("allow_session" to true, "allow_permanent" to true), + sessionId = "s1", + ) + assertEquals(listOf("once", "session", "always", "deny"), full.choices) + + val noAlways = + parseApprovalMap( + mapOf("allow_session" to true, "allow_permanent" to false), + sessionId = "s1", + ) + assertEquals(listOf("once", "session", "deny"), noAlways.choices) + + val explicit = + parseApprovalMap( + mapOf("choices" to listOf("custom", "deny")), + sessionId = "s1", + ) + assertEquals(listOf("custom", "deny"), explicit.choices) + } + + @Test + fun handleApprovalRequest_appendsSystemMessageAndSendsAckWithPreferredRuntimeId() = + testScope.runTest { + runtimeId = "runtime-session-xyz" + val event = + WsEvent.ApprovalRequest( + command = "git status", + description = "inspect working tree", + patternKeys = null, + sessionId = "event-session", + requestId = "req-123", + ) + + delegate.handleApprovalRequest(event) + advanceUntilIdle() + + val lastMsg = uiState.value.messages.last() + assertEquals(MessageRole.SYSTEM, lastMsg.role) + assertEquals("**Approval Required**\ninspect working tree", lastMsg.content) + assertEquals("req-123", lastMsg.approvalInfo?.requestId) + assertFalse(uiState.value.isAgentTyping) + + assertEquals(listOf(WsMethods.APPROVAL_RECEIVED), sentMethods) + assertEquals("runtime-session-xyz", sentParams.first()["session_id"]) + assertEquals("req-123", sentParams.first()["request_id"]) + } + + @Test + fun respondToApproval_clearsButtonsImmediatelyAndSendsChoiceOnce() = + testScope.runTest { + runtimeId = "runtime-session-xyz" + delegate.handleApprovalRequest( + WsEvent.ApprovalRequest( + command = "ls", + description = null, + patternKeys = null, + sessionId = null, + requestId = "req-999", + ), + ) + advanceUntilIdle() + sentMethods.clear() + sentParams.clear() + + delegate.respondToApproval("approve") + val messageBeforeIdle = uiState.value.messages.last() + assertNull(messageBeforeIdle.approvalInfo) + + advanceUntilIdle() + assertEquals(listOf(WsMethods.APPROVAL_RESPOND, WsMethods.APPROVAL_PENDING), sentMethods) + assertEquals("once", sentParams[0]["choice"]) + assertEquals("req-999", sentParams[0]["request_id"]) + assertEquals(false, sentParams[0]["all"]) + assertEquals("runtime-session-xyz", sentParams[0]["session_id"]) + assertEquals("runtime-session-xyz", sentParams[1]["session_id"]) + } + + @Test + fun maybeSurfacePendingApproval_dedupesExistingOnScreenRequestId() { + delegate.handleApprovalRequest( + WsEvent.ApprovalRequest( + command = "ls", + description = null, + patternKeys = null, + sessionId = null, + requestId = "dup-1", + ), + ) + val initialCount = uiState.value.messages.size + + delegate.maybeSurfacePendingApproval( + mapOf("command" to "ls", "request_id" to "dup-1"), + sessionId = "s1", + ) + assertEquals(initialCount, uiState.value.messages.size) + + delegate.maybeSurfacePendingApproval( + mapOf("command" to "pwd", "request_id" to "new-2"), + sessionId = "s1", + ) + assertEquals(initialCount + 1, uiState.value.messages.size) + assertEquals( + "new-2", + uiState.value.messages + .last() + .approvalInfo + ?.requestId, + ) + } + + @Test + fun handleApprovalRespondResult_appendsDesktopParitySystemMessage() { + delegate.handleApprovalRespondResult(mapOf("resolved" to 1)) + assertEquals(listOf("Approval submitted"), systemMessages) + } +} From 9d35bf7cdcda6f10159d872666be395918fd1fc3 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 00:03:42 +0300 Subject: [PATCH 06/42] refactor(chat): extract credential prompts delegate with tests --- .../ui/chat/ChatCredentialPromptsDelegate.kt | 180 ++++++++++++++++++ .../chat/ChatCredentialPromptsDelegateTest.kt | 87 +++++++++ 2 files changed, 267 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt new file mode 100644 index 00000000..8b47c45d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt @@ -0,0 +1,180 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Owns sudo and secret prompt state, timeouts, dismissal, and response RPCs. + * + * Extracted behavior-preservingly from [ChatViewModel] (issue #524). + */ +class ChatCredentialPromptsDelegate( + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher, + private val uiState: MutableStateFlow, + private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, + private val trackRequest: (id: String, method: String) -> Unit, +) { + /** + * The agent needs the user's sudo password. Surface a secure dialog and + * reply via sudo.respond. + */ + fun handleSudoRequest(event: WsEvent.SudoRequest) { + uiState.update { + it.copy( + sudoPrompt = SudoPromptUi(event.requestId, event.sessionId), + isAgentTyping = false, + ) + } + } + + /** + * Backend sudo timeout (120s) — clear only the matching dialog so a late + * expire for an old prompt never kills the current one. + */ + fun handleSudoExpire(event: WsEvent.SudoExpire) { + uiState.update { state -> + val current = state.sudoPrompt ?: return@update state + if (event.requestId != null && current.requestId != null && + event.requestId != current.requestId + ) { + return@update state + } + state.copy(sudoPrompt = null) + } + } + + /** + * The agent needs a secret value (token/password). Surface a secure dialog + * and reply via secret.respond. + */ + fun handleSecretRequest(event: WsEvent.SecretRequest) { + uiState.update { + it.copy( + secretPrompt = + SecretPromptUi( + event.requestId, + event.sessionId, + event.envVar, + event.prompt, + ), + isAgentTyping = false, + ) + } + } + + /** + * Backend secret timeout — match-only clear like [handleSudoExpire]. + */ + fun handleSecretExpire(event: WsEvent.SecretExpire) { + uiState.update { state -> + val current = state.secretPrompt ?: return@update state + if (event.requestId != null && current.requestId != null && + event.requestId != current.requestId + ) { + return@update state + } + state.copy(secretPrompt = null) + } + } + + /** + * Cancel → send empty password (desktop parity). Backend treats empty sudo as + * failed sudo (no command runs), unblocking the turn instantly. + */ + fun dismissSudo() { + val prompt = uiState.value.sudoPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId + uiState.update { it.copy(sudoPrompt = null) } + if (sessionId == null) return + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "password" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.SUDO_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) } + } + } + + /** + * Cancel → send empty value (desktop parity). Backend secret_cb returns + * skipped=True on empty, unblocking the turn instantly. + */ + fun dismissSecret() { + val prompt = uiState.value.secretPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId + uiState.update { it.copy(secretPrompt = null) } + if (sessionId == null) return + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "value" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.SECRET_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) } + } + } + + /** + * Send the user's sudo password back to the gateway. Clear prompt immediately, + * then fire the RPC. + */ + fun respondToSudo(password: String) { + val prompt = uiState.value.sudoPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return + if (password.isBlank()) return + + uiState.update { it.copy(sudoPrompt = null) } + + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "password" to password, + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.SUDO_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) } + } + } + + /** + * Send the user's secret value back to the gateway. + */ + fun respondToSecret(value: String) { + val prompt = uiState.value.secretPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return + if (value.isBlank()) return + + uiState.update { it.copy(secretPrompt = null) } + + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "value" to value, + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.SECRET_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) } + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt new file mode 100644 index 00000000..c888cdd1 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt @@ -0,0 +1,87 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatCredentialPromptsDelegateTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "sess-1")) + private val sentMethods = mutableListOf() + private val sentParams = mutableListOf>() + private val trackedRequests = mutableListOf>() + + private val delegate = + ChatCredentialPromptsDelegate( + scope = testScope, + ioDispatcher = testDispatcher, + uiState = uiState, + wsSend = { method, params, onSent -> + sentMethods.add(method) + sentParams.add(params) + onSent?.invoke("req-${sentMethods.size}") + }, + trackRequest = { id, method -> trackedRequests.add(id to method) }, + ) + + @Test + fun handleSudoRequest_andRespond_clearsPromptAndSendsRpc() = + testScope.runTest { + delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "r-sudo", sessionId = "s-1")) + assertEquals("r-sudo", uiState.value.sudoPrompt?.requestId) + assertFalse(uiState.value.isAgentTyping) + + delegate.respondToSudo("secret-pass") + assertNull(uiState.value.sudoPrompt) + + advanceUntilIdle() + assertEquals(listOf(WsMethods.SUDO_RESPOND), sentMethods) + assertEquals("secret-pass", sentParams.first()["password"]) + assertEquals("r-sudo", sentParams.first()["request_id"]) + assertEquals("s-1", sentParams.first()["session_id"]) + } + + @Test + fun handleSecretRequest_andDismiss_sendsEmptyValue() = + testScope.runTest { + delegate.handleSecretRequest( + WsEvent.SecretRequest( + requestId = "r-sec", + sessionId = "s-1", + envVar = "OPENAI_API_KEY", + prompt = "Enter key", + ), + ) + assertEquals("OPENAI_API_KEY", uiState.value.secretPrompt?.envVar) + + delegate.dismissSecret() + assertNull(uiState.value.secretPrompt) + + advanceUntilIdle() + assertEquals(listOf(WsMethods.SECRET_RESPOND), sentMethods) + assertEquals("", sentParams.first()["value"]) + assertEquals("r-sec", sentParams.first()["request_id"]) + } + + @Test + fun expireHandlers_ignoreMismatchedRequestId() { + delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "keep-me", sessionId = "s-1")) + delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "other-req", sessionId = "s-1")) + assertNotNull(uiState.value.sudoPrompt) + + delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "keep-me", sessionId = "s-1")) + assertNull(uiState.value.sudoPrompt) + } +} From 280a03ea235afa8cf27d592c6797a0530955eaaf Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 00:03:46 +0300 Subject: [PATCH 07/42] refactor(chat): extract model switch delegate with tests --- .../ui/chat/ChatModelSwitchDelegate.kt | 286 ++++++++++++++++++ .../ui/chat/ChatModelSwitchDelegateTest.kt | 141 +++++++++ 2 files changed, 427 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt new file mode 100644 index 00000000..44e2996c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -0,0 +1,286 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.ModelCapabilities +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks an in-flight `config.set key=model` RPC and the model it would replace + * so confirmation dialog dismiss can roll back optimistically. + */ +data class ActiveModelSwitch( + val spec: String, + val previousModel: String?, +) + +/** + * Owns model picker loading/caching, in-session model switches, model capabilities, + * pinned model toggling, and expensive model switch confirmation dialogs. + * + * Extracted behavior-preservingly from [ChatViewModel] (issue #589). + */ +class ChatModelSwitchDelegate( + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher, + private val uiState: MutableStateFlow, + private val runtimeSessionId: () -> String?, + private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, + private val trackRequest: (id: String, method: String) -> Unit, + private val addAssistantMessage: (text: String) -> Unit, + private val handleSlashCommand: (command: String) -> Unit, + private val fetchContextUsage: () -> Unit, + private val getModelOptionsCall: suspend ( + refresh: Boolean, + ) -> NetworkResult = + { refresh -> safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh) } }, + private val getPinnedModels: () -> List = { AuthManager.getPinnedModels() }, + private val savePinnedModels: (List) -> Unit = { AuthManager.savePinnedModels(it) }, +) { + val pendingModelSwitchRequests = ConcurrentHashMap() + var activeModelSwitchConfirmation: ActiveModelSwitch? = null + var optimisticPreviousModel: String? = null + var cachedModelOptions: List = emptyList() + + fun isModelPickerCommand(command: String): Boolean { + val trimmed = command.trim() + if (!trimmed.startsWith("/", ignoreCase = true)) return false + val body = trimmed.removePrefix("/").trimStart() + return body.equals("model", ignoreCase = true) || + ( + body.startsWith("model ", ignoreCase = true) && + body.substringAfter("model").trim().isEmpty() + ) + } + + fun preloadModelOptions() { + scope.launch(ioDispatcher) { + val result = getModelOptionsCall(false) + if (result is NetworkResult.Success) { + cachedModelOptions = result.data.providers.orEmpty() + uiState.update { it.copy(modelPickerPinned = getPinnedModels()) } + syncCurrentModelCapabilities() + } + } + } + + fun openModelPicker() { + val hasCached = cachedModelOptions.isNotEmpty() + uiState.update { + it.copy( + showModelPicker = true, + modelPickerProviders = if (hasCached) cachedModelOptions else emptyList(), + modelPickerPinned = getPinnedModels(), + modelPickerLoading = !hasCached, + ) + } + if (!hasCached) { + refreshModelOptions() + } + } + + fun refreshModelOptions() { + uiState.update { it.copy(modelPickerLoading = true) } + scope.launch(ioDispatcher) { + val result = getModelOptionsCall(true) + when (result) { + is NetworkResult.Success -> { + cachedModelOptions = result.data.providers.orEmpty() + uiState.update { + it.copy( + modelPickerProviders = cachedModelOptions, + modelPickerLoading = false, + ) + } + syncCurrentModelCapabilities() + } + + is NetworkResult.Failure -> { + uiState.update { + it.copy( + modelPickerLoading = false, + errorMessage = "Failed to load models: ${result.error.message}", + ) + } + } + } + } + } + + fun closeModelPicker() { + uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) } + } + + fun togglePinModel( + providerSlug: String, + modelName: String, + ) { + val currentPinned = getPinnedModels().toMutableList() + val target = PinnedModel(providerSlug, modelName) + if (currentPinned.contains(target)) { + currentPinned.remove(target) + } else { + currentPinned.add(target) + } + savePinnedModels(currentPinned) + uiState.update { it.copy(modelPickerPinned = currentPinned) } + } + + fun sendSlashModel( + provider: String, + model: String, + ) { + optimisticPreviousModel = uiState.value.currentSessionModel + uiState.update { + it.copy( + showModelPicker = false, + modelPickerLoading = false, + currentSessionModel = "$provider/$model", + ) + } + syncCurrentModelCapabilities() + fetchContextUsage() + handleSlashCommand("/model $model --provider $provider --session") + } + + fun handleModelSwitch( + command: String, + confirmExpensive: Boolean = false, + ) { + val sessionId = runtimeSessionId() + if (sessionId == null) { + addAssistantMessage("No active session. Use `/new` to create one.") + return + } + val spec = + if (command.startsWith("/model", ignoreCase = true)) { + command.substring(6).trim() + } else { + command.trim() + } + val previousModel = optimisticPreviousModel ?: uiState.value.currentSessionModel + optimisticPreviousModel = null + val params = + mutableMapOf( + "key" to "model", + "value" to spec, + "session_id" to sessionId, + ) + if (confirmExpensive) { + params["confirm_expensive_model"] = true + } + scope.launch(ioDispatcher) { + wsSend( + WsMethods.CONFIG_SET, + params, + ) { id -> + trackRequest(id, WsMethods.CONFIG_SET) + pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel) + } + } + } + + fun handleConfigSetResult( + id: String, + result: Any?, + ) { + val pending = pendingModelSwitchRequests.remove(id) + val map = result as? Map<*, *> ?: return + val key = map["key"] as? String + if (key == "model") { + val confirmRequired = map["confirm_required"] as? Boolean ?: false + if (confirmRequired) { + val confirmMessage = + (map["confirm_message"] as? String) + ?: (map["warning"] as? String) + ?: "This model requires confirmation to switch. Continue?" + activeModelSwitchConfirmation = pending + uiState.update { + it.copy(modelSwitchConfirmMessage = confirmMessage) + } + } + } + } + + fun dismissModelSwitchConfirm() { + val pending = activeModelSwitchConfirmation + activeModelSwitchConfirmation = null + uiState.update { + it.copy( + modelSwitchConfirmMessage = null, + currentSessionModel = pending?.previousModel ?: it.currentSessionModel, + ) + } + syncCurrentModelCapabilities() + fetchContextUsage() + } + + fun confirmModelSwitchExpensive() { + val pending = activeModelSwitchConfirmation + activeModelSwitchConfirmation = null + uiState.update { it.copy(modelSwitchConfirmMessage = null) } + if (pending != null) { + handleModelSwitch(pending.spec, confirmExpensive = true) + } + } + + fun setReasoningLevel(level: String?) { + uiState.update { it.copy(reasoningLevel = level) } + val sessionId = runtimeSessionId() ?: return + if (level == null) return + scope.launch(ioDispatcher) { + wsSend( + WsMethods.CONFIG_SET, + mapOf( + "key" to "reasoning", + "value" to level, + "session_id" to sessionId, + ), + ) { id -> trackRequest(id, WsMethods.CONFIG_SET) } + } + } + + fun getModelCapabilities( + providerSlug: String, + modelName: String, + ): ModelCapabilities? = cachedModelOptions.find { it.slug == providerSlug }?.capabilities?.get(modelName) + + fun getCurrentModelCapabilities(): ModelCapabilities? { + val label = uiState.value.currentSessionModel ?: return null + val idx = label.indexOf('/') + if (idx <= 0) return null + val provider = label.substring(0, idx) + val model = label.substring(idx + 1) + return getModelCapabilities(provider, model) + } + + fun syncCurrentModelCapabilities() { + val label = uiState.value.currentSessionModel + val caps = + if (label == null) { + null + } else { + val idx = label.indexOf('/') + if (idx <= 0) { + null + } else { + val provider = label.substring(0, idx) + val model = label.substring(idx + 1) + cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model) + } + } + uiState.update { state -> + if (state.currentModelCapabilities != caps) state.copy(currentModelCapabilities = caps) else state + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt new file mode 100644 index 00000000..346af7c8 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt @@ -0,0 +1,141 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.ModelCapabilities +import com.m57.hermescontrol.data.model.ModelOptionsResponse +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatModelSwitchDelegateTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val uiState = + MutableStateFlow(ChatUiState(currentSessionId = "sess-1", currentSessionModel = "anthropic/claude-3")) + private var runtimeId: String? = "runtime-1" + private val sentMethods = mutableListOf() + private val sentParams = mutableListOf>() + private val slashCommands = mutableListOf() + private val assistantMessages = mutableListOf() + private var contextRefetched = 0 + private var pinnedList = mutableListOf(PinnedModel("anthropic", "claude-3")) + + private val fakeResponse = + ModelOptionsResponse( + providers = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-4o"), + capabilities = mapOf("gpt-4o" to ModelCapabilities(reasoning = true)), + ), + ), + ) + + private val delegate = + ChatModelSwitchDelegate( + scope = testScope, + ioDispatcher = testDispatcher, + uiState = uiState, + runtimeSessionId = { runtimeId }, + wsSend = { method, params, onSent -> + sentMethods.add(method) + sentParams.add(params) + onSent?.invoke("req-${sentMethods.size}") + }, + trackRequest = { _, _ -> }, + addAssistantMessage = { assistantMessages.add(it) }, + handleSlashCommand = { slashCommands.add(it) }, + fetchContextUsage = { contextRefetched++ }, + getModelOptionsCall = { NetworkResult.Success(fakeResponse) }, + getPinnedModels = { pinnedList }, + savePinnedModels = { pinnedList = it.toMutableList() }, + ) + + @Test + fun isModelPickerCommand_identifiesBareModelCommandOnly() { + assertTrue(delegate.isModelPickerCommand("/model")) + assertTrue(delegate.isModelPickerCommand("/MODEL ")) + assertTrue(delegate.isModelPickerCommand(" /model ")) + assertFalse(delegate.isModelPickerCommand("/model openai/gpt-4o")) + assertFalse(delegate.isModelPickerCommand("hello /model")) + } + + @Test + fun openModelPicker_andPreload_populatesProvidersAndPinned() = + testScope.runTest { + delegate.preloadModelOptions() + advanceUntilIdle() + + delegate.openModelPicker() + assertTrue(uiState.value.showModelPicker) + assertEquals(1, uiState.value.modelPickerProviders.size) + assertFalse(uiState.value.modelPickerLoading) + } + + @Test + fun sendSlashModel_optimisticallyUpdatesModelAndDispatchesSlash() { + delegate.sendSlashModel("openai", "gpt-4o") + assertEquals("openai/gpt-4o", uiState.value.currentSessionModel) + assertFalse(uiState.value.showModelPicker) + assertEquals(listOf("/model gpt-4o --provider openai --session"), slashCommands) + assertEquals(1, contextRefetched) + } + + @Test + fun handleModelSwitch_stripsLeadingSlashModel_andHandlesConfirmation() = + testScope.runTest { + delegate.handleModelSwitch("/MODEL gpt-4o --provider openai --session") + advanceUntilIdle() + + assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods) + assertEquals("gpt-4o --provider openai --session", sentParams.first()["value"]) + + delegate.handleConfigSetResult( + id = "req-1", + result = + mapOf( + "key" to "model", + "confirm_required" to true, + "confirm_message" to "High token cost ahead", + ), + ) + assertEquals("High token cost ahead", uiState.value.modelSwitchConfirmMessage) + + delegate.confirmModelSwitchExpensive() + advanceUntilIdle() + assertEquals(2, sentMethods.size) + assertEquals(true, sentParams[1]["confirm_expensive_model"]) + } + + @Test + fun dismissModelSwitchConfirm_revertsToPreviousModel() = + testScope.runTest { + delegate.sendSlashModel("opencode-free", "muse-spark-1.3-contributor-free") + advanceUntilIdle() + // In real ChatViewModel, handleSlashCommand routes to handleModelSwitch: + delegate.handleModelSwitch("/model muse-spark-1.3-contributor-free --provider opencode-free --session") + advanceUntilIdle() + delegate.handleConfigSetResult( + id = "req-1", + result = mapOf("key" to "model", "confirm_required" to true), + ) + + delegate.dismissModelSwitchConfirm() + assertNull(uiState.value.modelSwitchConfirmMessage) + assertEquals("anthropic/claude-3", uiState.value.currentSessionModel) + } +} From 3e012dbf3148e969e6d2a5177825114158c31fe0 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 00:03:54 +0300 Subject: [PATCH 08/42] refactor(chat): extract rpc request tracker with tests --- .../ui/chat/ChatRpcRequestTracker.kt | 111 ++++++++++++++++++ .../ui/chat/ChatRpcRequestTrackerTest.kt | 94 +++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTracker.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTrackerTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTracker.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTracker.kt new file mode 100644 index 00000000..8492ab54 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTracker.kt @@ -0,0 +1,111 @@ +package com.m57.hermescontrol.ui.chat + +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks an in-flight RPC request associated with a specific session generation + * and resume sequence to guard against stale results/errors from previous sessions. + */ +data class TrackedSessionRequest( + val generation: Long, + val resumeSequence: Long = 0L, + val sessionId: String? = null, +) + +/** + * Owns RPC id-to-method mapping, session request generations, hydration sequences, + * and staleness detection. + * + * Extracted behavior-preservingly from [ChatViewModel] to ensure deterministic + * RPC tracking across session creation, switches, resumes, and reconnects. + */ +class ChatRpcRequestTracker( + private val getCurrentSessionId: () -> String?, +) { + /** Maps an in-flight RPC id to its method for UI error labeling. */ + private val idToMethod = ConcurrentHashMap() + private val sessionRequestById = ConcurrentHashMap() + + var sessionGeneration: Long = 0L + private set + + var resumeRequestSequence: Long = 0L + private set + var activeResumeRequestSequence: Long = 0L + private set + + var hydrationRequestSequence: Long = 0L + private set + var activeHydrationRequestSequence: Long = 0L + private set + + var resumedGeneration: Long = -1L + var hydratedGeneration: Long = -1L + + fun nextSessionGeneration(): Long { + sessionGeneration++ + return sessionGeneration + } + + fun nextResumeSequence(): Long { + resumeRequestSequence++ + activeResumeRequestSequence = resumeRequestSequence + return resumeRequestSequence + } + + fun nextHydrationSequence(): Long { + hydrationRequestSequence++ + activeHydrationRequestSequence = hydrationRequestSequence + return hydrationRequestSequence + } + + fun trackRequest( + id: String, + method: String, + ) { + idToMethod[id] = method + } + + fun trackSessionRequest( + id: String, + method: String, + generation: Long, + resumeSequence: Long = 0L, + sessionId: String? = null, + ) { + sessionRequestById[id] = TrackedSessionRequest(generation, resumeSequence, sessionId) + trackRequest(id, method) + } + + fun isCurrentSessionRequest( + sessionId: String, + generation: Long, + ): Boolean = generation == sessionGeneration && sessionId == getCurrentSessionId() + + fun isCurrentHydration( + sessionId: String, + generation: Long, + requestSequence: Long, + ): Boolean = requestSequence == activeHydrationRequestSequence && isCurrentSessionRequest(sessionId, generation) + + fun isStaleSessionRequest(id: String): Boolean = sessionRequestById[id]?.let(::isStaleSessionRequest) == true + + fun isStaleSessionRequest(request: TrackedSessionRequest): Boolean = + request.generation != sessionGeneration || + (request.sessionId != null && request.sessionId != getCurrentSessionId()) || + (request.resumeSequence != 0L && request.resumeSequence != activeResumeRequestSequence) + + fun removeMethod(id: String): String? = idToMethod.remove(id) + + fun removeSessionRequest(id: String): TrackedSessionRequest? = sessionRequestById.remove(id) + + fun forgetRequest(id: String) { + idToMethod.remove(id) + sessionRequestById.remove(id) + } + + fun resetSessionStateTracking() { + resumedGeneration = -1L + hydratedGeneration = -1L + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTrackerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTrackerTest.kt new file mode 100644 index 00000000..78eac772 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTrackerTest.kt @@ -0,0 +1,94 @@ +package com.m57.hermescontrol.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatRpcRequestTrackerTest { + private var currentSessionId: String? = "session-a" + private val tracker = ChatRpcRequestTracker { currentSessionId } + + @Test + fun trackRequest_andRemoveMethod() { + tracker.trackRequest("req-1", "session.create") + assertEquals("session.create", tracker.removeMethod("req-1")) + assertNull(tracker.removeMethod("req-1")) + } + + @Test + fun generationIncrement_detectsStaleGenerations() { + val gen1 = tracker.nextSessionGeneration() + assertEquals(1L, gen1) + + tracker.trackSessionRequest( + id = "req-resume-1", + method = "session.resume", + generation = gen1, + sessionId = "session-a", + ) + assertFalse(tracker.isStaleSessionRequest("req-resume-1")) + + val gen2 = tracker.nextSessionGeneration() + assertEquals(2L, gen2) + assertTrue(tracker.isStaleSessionRequest("req-resume-1")) + } + + @Test + fun sessionMismatch_marksRequestStale() { + val gen = tracker.nextSessionGeneration() + tracker.trackSessionRequest( + id = "req-2", + method = "session.resume", + generation = gen, + sessionId = "session-a", + ) + assertFalse(tracker.isStaleSessionRequest("req-2")) + + currentSessionId = "session-b" + assertTrue(tracker.isStaleSessionRequest("req-2")) + } + + @Test + fun resumeSequence_marksSupersededResumeStale() { + val gen = tracker.nextSessionGeneration() + val seq1 = tracker.nextResumeSequence() + tracker.trackSessionRequest( + id = "req-resume-seq1", + method = "session.resume", + generation = gen, + resumeSequence = seq1, + sessionId = "session-a", + ) + assertFalse(tracker.isStaleSessionRequest("req-resume-seq1")) + + val seq2 = tracker.nextResumeSequence() + assertEquals(2L, seq2) + assertTrue(tracker.isStaleSessionRequest("req-resume-seq1")) + } + + @Test + fun hydrationSequence_guardsActiveHydration() { + val gen = tracker.nextSessionGeneration() + val hyd1 = tracker.nextHydrationSequence() + assertTrue(tracker.isCurrentHydration("session-a", gen, hyd1)) + + val hyd2 = tracker.nextHydrationSequence() + assertFalse(tracker.isCurrentHydration("session-a", gen, hyd1)) + assertTrue(tracker.isCurrentHydration("session-a", gen, hyd2)) + } + + @Test + fun forgetRequest_removesBothTrackingEntries() { + tracker.trackSessionRequest( + id = "req-forget", + method = "session.resume", + generation = tracker.sessionGeneration, + sessionId = "session-a", + ) + tracker.forgetRequest("req-forget") + assertNull(tracker.removeMethod("req-forget")) + assertNull(tracker.removeSessionRequest("req-forget")) + } +} From b9ee61d429c559bd4855019386fbb8ad95843fd2 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 00:03:58 +0300 Subject: [PATCH 09/42] refactor(remote): extract chat relogin authenticator --- .../data/remote/ChatReloginAuthenticator.kt | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/remote/ChatReloginAuthenticator.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ChatReloginAuthenticator.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ChatReloginAuthenticator.kt new file mode 100644 index 00000000..643fe1c7 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ChatReloginAuthenticator.kt @@ -0,0 +1,122 @@ +package com.m57.hermescontrol.data.remote + +import com.m57.hermescontrol.data.local.AuthManager +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Handles basic credential login and WS ticket minting for in-chat reconnect/re-authentication. + * + * Extracted from [com.m57.hermescontrol.ui.chat.ChatViewModel] to isolate raw OkHttp network calls. + */ +class ChatReloginAuthenticator( + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, +) { + suspend fun relogin( + username: String, + password: String, + onSuccess: () -> Unit, + onFailure: (String) -> Unit, + ) { + withContext(ioDispatcher) { + val endpoint = AuthManager.endpointForBuild() + val jsonMediaType = "application/json; charset=utf-8".toMediaType() + val jsonBody = + JSONObject() + .put("provider", "basic") + .put("username", username) + .put("password", password) + .put("next", "") + .toString() + + try { + val loginClient = + OkHttpProvider.probe + .newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + + val loginReq = + Request + .Builder() + .url(endpoint.resolve("auth/password-login").toString()) + .header("Content-Type", "application/json") + .post(jsonBody.toRequestBody(jsonMediaType)) + .build() + + loginClient.newCall(loginReq).execute().use { loginResp -> + if (!loginResp.isSuccessful) { + val msg = + when (loginResp.code) { + 401 -> "Invalid username or password (401)" + 403 -> "Forbidden (403)" + else -> "HTTP error code: ${loginResp.code}" + } + withContext(mainDispatcher) { + onFailure(msg) + } + return@withContext + } + } + + val ticketClient = + OkHttpProvider.base + .newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + + val ticketReq = + Request + .Builder() + .url(endpoint.resolve("api/auth/ws-ticket").toString()) + .post("{}".toRequestBody(jsonMediaType)) + .build() + + ticketClient.newCall(ticketReq).execute().use { ticketResp -> + if (!ticketResp.isSuccessful) { + withContext(mainDispatcher) { + onFailure("Failed to mint WS ticket: HTTP ${ticketResp.code}") + } + return@withContext + } + + val body = ticketResp.body.string() + val ticket = JSONObject(body).optString("ticket").takeIf { it.isNotBlank() } + + if (ticket.isNullOrBlank()) { + withContext(mainDispatcher) { + onFailure("Invalid ticket returned from server") + } + return@withContext + } + + AuthManager.setWsAuthParam("ticket") + AuthManager.setToken(ticket) + + withContext(mainDispatcher) { + onSuccess() + } + } + } catch (e: IOException) { + withContext(mainDispatcher) { + onFailure("Connection failed: ${e.message}") + } + } catch (e: JSONException) { + withContext(mainDispatcher) { + onFailure("Connection failed: ${e.message}") + } + } + } + } +} From 0af4782da1f492a8531d7ef09290c16aff5821a9 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 00:04:09 +0300 Subject: [PATCH 10/42] refactor(chat): wire extracted delegates into chat viewmodel --- .../hermescontrol/ui/chat/ChatViewModel.kt | 820 ++---------------- 1 file changed, 87 insertions(+), 733 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 90910bc5..bdf18ffa 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -515,9 +515,6 @@ class ChatViewModel( val previousModel: String?, ) - private val pendingModelSwitchRequests = ConcurrentHashMap() - private var activeModelSwitchConfirmation: ActiveModelSwitch? = null - private var optimisticPreviousModel: String? = null private var resumeRequestSequence = 0L private var activeResumeRequestSequence = 0L private var hydrationRequestSequence = 0L @@ -615,12 +612,44 @@ class ChatViewModel( ioDispatcher = ioDispatcher, ) - /** - * Model options cached from GET /api/model/options so the in-session model - * picker (issue #589) opens instantly when the user types /model or taps the - * top-bar chip. Preloaded at GatewayReady; refreshed on open if empty. - */ - private var cachedModelOptions: List = emptyList() + private val reloginAuthenticator = + com.m57.hermescontrol.data.remote.ChatReloginAuthenticator( + ioDispatcher = ioDispatcher, + mainDispatcher = Dispatchers.Main, + ) + + private val modelSwitchDelegate = + ChatModelSwitchDelegate( + scope = viewModelScope, + ioDispatcher = ioDispatcher, + uiState = _uiState, + runtimeSessionId = { runtimeSessionId }, + wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) }, + trackRequest = { id, method -> trackRequest(id, method) }, + addAssistantMessage = { text -> addAssistantMessage(text) }, + handleSlashCommand = { cmd -> handleSlashCommand(cmd) }, + fetchContextUsage = { fetchContextUsage() }, + ) + + private val credentialPromptsDelegate = + ChatCredentialPromptsDelegate( + scope = viewModelScope, + ioDispatcher = ioDispatcher, + uiState = _uiState, + wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) }, + trackRequest = { id, method -> trackRequest(id, method) }, + ) + + private val approvalsDelegate = + ChatApprovalsDelegate( + scope = viewModelScope, + ioDispatcher = ioDispatcher, + uiState = _uiState, + runtimeSessionId = { runtimeSessionId }, + wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) }, + trackRequest = { id, method -> trackRequest(id, method) }, + addSystemMessage = { text -> addSystemMessage(text) }, + ) private val streamingController = ChatStreamingController( @@ -796,7 +825,7 @@ class ChatViewModel( addSystemMessage("Connected to Hermes") loadSessions() fetchCommandCatalog() - preloadModelOptions() + modelSwitchDelegate.preloadModelOptions() val currentId = _uiState.value.currentSessionId if (currentId != null) { if (sessionHasServerPresence) { @@ -944,7 +973,7 @@ class ChatViewModel( fullContextTokens = if (modelSwapped) null else state.fullContextTokens, ) } - syncCurrentModelCapabilities() + modelSwitchDelegate.syncCurrentModelCapabilities() if (modelSwapped) { // Issue #817: after a swap the REST model/info window is // PROFILE-scoped and may describe the old model (e.g. a @@ -957,18 +986,10 @@ class ChatViewModel( // reconciliation) — surface it unless already on screen. val pendingApproval = info["pending_approval"] as? Map<*, *> if (pendingApproval != null) { - val rid = pendingApproval["request_id"] as? String - val alreadyShown = - rid != null && - _uiState.value.messages.any { it.approvalInfo?.requestId == rid } - if (!alreadyShown) { - handleApprovalRequest( - parseApprovalMap( - pendingApproval, - runtimeSessionId ?: _uiState.value.currentSessionId, - ), - ) - } + approvalsDelegate.maybeSurfacePendingApproval( + pendingApproval, + runtimeSessionId ?: _uiState.value.currentSessionId, + ) } } } @@ -1034,23 +1055,23 @@ class ChatViewModel( } is WsEvent.ApprovalRequest -> { - handleApprovalRequest(event) + approvalsDelegate.handleApprovalRequest(event) } is WsEvent.SudoRequest -> { - handleSudoRequest(event) + credentialPromptsDelegate.handleSudoRequest(event) } is WsEvent.SudoExpire -> { - handleSudoExpire(event) + credentialPromptsDelegate.handleSudoExpire(event) } is WsEvent.SecretRequest -> { - handleSecretRequest(event) + credentialPromptsDelegate.handleSecretRequest(event) } is WsEvent.SecretExpire -> { - handleSecretExpire(event) + credentialPromptsDelegate.handleSecretExpire(event) } is WsEvent.GatewayError -> { @@ -1244,7 +1265,7 @@ class ChatViewModel( terminalBackend = terminalBackend ?: it.terminalBackend, ) } - syncCurrentModelCapabilities() + modelSwitchDelegate.syncCurrentModelCapabilities() // Mirror the active runtime session id app-wide (issue #532). ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId) addSystemMessage("Session resumed") @@ -1257,21 +1278,13 @@ class ChatViewModel( // the full queue in case more are parked. val pendingApproval = resultMap?.get("pending_approval") as? Map<*, *> if (pendingApproval != null) { - val rid = pendingApproval["request_id"] as? String - val alreadyShown = - rid != null && - _uiState.value.messages.any { it.approvalInfo?.requestId == rid } - if (!alreadyShown) { - handleApprovalRequest( - parseApprovalMap( - pendingApproval, - runtimeSessionId ?: sessionId, - ), - ) - } + approvalsDelegate.maybeSurfacePendingApproval( + pendingApproval, + runtimeSessionId ?: sessionId, + ) } val activeSessionId = runtimeSessionId ?: sessionId - if (activeSessionId != null) replayPendingApproval(activeSessionId) + if (activeSessionId != null) approvalsDelegate.replayPendingApproval(activeSessionId) } WsMethods.SESSION_INTERRUPT -> { @@ -1304,34 +1317,15 @@ class ChatViewModel( } WsMethods.APPROVAL_RESPOND -> { - val map = result as? Map<*, *> - val resolved = (map?.get("resolved") as? Number)?.toInt() ?: 0 - if (resolved > 0) { - addSystemMessage("Approval submitted") - } + approvalsDelegate.handleApprovalRespondResult(result) } WsMethods.APPROVAL_PENDING -> { - handleApprovalPendingResult(result) + approvalsDelegate.handleApprovalPendingResult(result) } WsMethods.CONFIG_SET -> { - val pending = pendingModelSwitchRequests.remove(id) - val map = result as? Map<*, *> ?: return - val key = map["key"] as? String - if (key == "model") { - val confirmRequired = map["confirm_required"] as? Boolean ?: false - if (confirmRequired) { - val confirmMessage = - (map["confirm_message"] as? String) - ?: (map["warning"] as? String) - ?: "This model requires confirmation to switch. Continue?" - activeModelSwitchConfirmation = pending - _uiState.update { - it.copy(modelSwitchConfirmMessage = confirmMessage) - } - } - } + modelSwitchDelegate.handleConfigSetResult(id, result) } } } @@ -1455,7 +1449,7 @@ class ChatViewModel( if (trimmed.startsWith("/", ignoreCase = true)) { // Issue #589: a bare "/model" (no argument) opens the picker instead // of requiring the user to hand-type the provider/model. - if (isModelPickerCommand(trimmed)) { + if (modelSwitchDelegate.isModelPickerCommand(trimmed)) { openModelPicker() return } @@ -1880,7 +1874,7 @@ class ChatViewModel( } is SlashResult.ModelSwitch -> { - handleModelSwitch(command) + modelSwitchDelegate.handleModelSwitch(command) } is SlashResult.Update -> { @@ -2123,68 +2117,6 @@ class ChatViewModel( } } - /** - * Hot-swap the current session's model via the backend's model-switch - * mechanism (issue #589). - * - * The TUI gateway's `prompt.submit` does NOT parse slash commands (it would - * make the LLM treat "/model ..." as a chat message), and `command.dispatch` - * only knows quick/plugin/bundle/skill commands (4018s on /model). The - * correct RPC is `config.set` with `key="model"` — the 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. - * - * IMPORTANT: `config.set` key=model passes the value DIRECTLY to - * `parse_model_flags` (it does NOT strip a leading "/model" like slash.exec / - * prompt.submit do). So we strip the "/model" prefix here and send the bare - * spec `parse_model_flags` understands: - * ` --provider --session` - * (matching the TUI client's `modelValueForConfigSet`). - */ - private fun handleModelSwitch( - command: String, - confirmExpensive: Boolean = false, - ) { - val sessionId = runtimeSessionId - if (sessionId == null) { - addAssistantMessage("No active session. Use `/new` to create one.") - return - } - // Strip a leading "/model" (and any following whitespace) — config.set - // key=model expects the bare spec, not a slash command. Match the - // dispatcher's case-insensitive "/model" detection so a typed "/MODEL" - // (or any casing) doesn't forward the literal slash prefix to the - // backend, where parse_model_flags wouldn't recognize it. - val spec = - if (command.startsWith("/model", ignoreCase = true)) { - command.substring(6).trim() - } else { - command.trim() - } - val previousModel = optimisticPreviousModel ?: _uiState.value.currentSessionModel - optimisticPreviousModel = null - val params = - mutableMapOf( - "key" to "model", - "value" to spec, - "session_id" to sessionId, - ) - if (confirmExpensive) { - params["confirm_expensive_model"] = true - } - viewModelScope.launch(ioDispatcher) { - wsClient.send( - WsMethods.CONFIG_SET, - params, - onSent = { id -> - trackRequest(id, WsMethods.CONFIG_SET) - pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel) - }, - ) - } - } - /** * Submits [text] as a prompt to the current session via WS, without * adding a duplicate user message. Used by [handleDispatchResult] when @@ -2470,220 +2402,34 @@ class ChatViewModel( // ── In-session model picker (issue #589) ───────────────────────────── - /** - * Whether [command] should open the in-session model picker instead of being - * dispatched as a normal slash command. True for a bare `/model` (with no - * trailing model argument) — the picker supplies the argument interactively. - * A fully-typed `/model provider/model` is forwarded straight to the backend. - */ - private fun isModelPickerCommand(command: String): Boolean { - val trimmed = command.trim() - if (!trimmed.startsWith("/", ignoreCase = true)) return false - val body = trimmed.removePrefix("/").trimStart() - // Must be exactly "model" with no argument (or just whitespace). - return body.equals("model", ignoreCase = true) || - ( - body.startsWith("model ", ignoreCase = true) && - body.substringAfter("model").trim().isEmpty() - ) - } + fun openModelPicker() = modelSwitchDelegate.openModelPicker() - /** Preload model options so the picker opens instantly (no spinner on /model). */ - private fun preloadModelOptions() { - viewModelScope.launch(ioDispatcher) { - val result = - safeApiCall { - ApiClient.hermesApi.getModelOptions(refresh = false) - } - if (result is NetworkResult.Success) { - cachedModelOptions = result.data.providers.orEmpty() - _uiState.update { it.copy(modelPickerPinned = AuthManager.getPinnedModels()) } - syncCurrentModelCapabilities() - } - } - } + fun refreshModelOptions() = modelSwitchDelegate.refreshModelOptions() - /** - * Open the in-session model picker. Uses the preloaded options if available - * (instant open); otherwise shows a loading state and fetches them. The - * `/model` slash command is the supported session hot-swap mechanism per the - * backend contract (issue #589). - */ - fun openModelPicker() { - val hasCached = cachedModelOptions.isNotEmpty() - _uiState.update { - it.copy( - showModelPicker = true, - modelPickerProviders = if (hasCached) cachedModelOptions else emptyList(), - modelPickerPinned = AuthManager.getPinnedModels(), - modelPickerLoading = !hasCached, - ) - } - if (!hasCached) { - refreshModelOptions() - } - } - - /** Re-fetch options (pull-to-refresh style) when the picker is already open. */ - fun refreshModelOptions() { - _uiState.update { it.copy(modelPickerLoading = true) } - viewModelScope.launch(ioDispatcher) { - val result = - safeApiCall { - ApiClient.hermesApi.getModelOptions(refresh = true) - } - when (result) { - is NetworkResult.Success -> { - cachedModelOptions = result.data.providers.orEmpty() - _uiState.update { - it.copy( - modelPickerProviders = cachedModelOptions, - modelPickerLoading = false, - ) - } - syncCurrentModelCapabilities() - } - - is NetworkResult.Failure -> { - _uiState.update { - it.copy( - modelPickerLoading = false, - errorMessage = "Failed to load models: ${result.error.message}", - ) - } - } - } - } - } - - fun closeModelPicker() { - _uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) } - } + fun closeModelPicker() = modelSwitchDelegate.closeModelPicker() fun togglePinModel( providerSlug: String, modelName: String, - ) { - val currentPinned = AuthManager.getPinnedModels().toMutableList() - val target = PinnedModel(providerSlug, modelName) - if (currentPinned.contains(target)) { - currentPinned.remove(target) - } else { - currentPinned.add(target) - } - AuthManager.savePinnedModels(currentPinned) - _uiState.update { it.copy(modelPickerPinned = currentPinned) } - } + ) = modelSwitchDelegate.togglePinModel(providerSlug, modelName) - /** - * Hot-swap the CURRENT session's model via the /model slash command. - * - * Builds the backend-valid command form `/model --provider - * --session`. The `--session` flag keeps the switch scoped to this chat - * only (it writes a per-session override and never touches the global - * model config), per the backend model-switch contract. - */ fun sendSlashModel( provider: String, model: String, - ) { - optimisticPreviousModel = _uiState.value.currentSessionModel - _uiState.update { - it.copy( - showModelPicker = false, - modelPickerLoading = false, - // Optimistic: reflect the chosen model in the top-bar chip until - // the next session sync confirms the backend hot-swap. - currentSessionModel = "$provider/$model", - ) - } - syncCurrentModelCapabilities() - // Model switch changes the context-window denominator — refetch it. - fetchContextUsage() - handleSlashCommand("/model $model --provider $provider --session") - } + ) = modelSwitchDelegate.sendSlashModel(provider, model) - fun dismissModelSwitchConfirm() { - val pending = activeModelSwitchConfirmation - activeModelSwitchConfirmation = null - _uiState.update { - it.copy( - modelSwitchConfirmMessage = null, - currentSessionModel = pending?.previousModel ?: it.currentSessionModel, - ) - } - syncCurrentModelCapabilities() - fetchContextUsage() - } + fun dismissModelSwitchConfirm() = modelSwitchDelegate.dismissModelSwitchConfirm() - fun confirmModelSwitchExpensive() { - val pending = activeModelSwitchConfirmation - activeModelSwitchConfirmation = null - _uiState.update { it.copy(modelSwitchConfirmMessage = null) } - if (pending != null) { - handleModelSwitch(pending.spec, confirmExpensive = true) - } - } + fun confirmModelSwitchExpensive() = modelSwitchDelegate.confirmModelSwitchExpensive() - /** - * Set the reasoning effort level for the current session. - * - * Updates the UI optimistically and sends a `config.set` RPC to the - * backend. The level applies per-session via the runtime session ID. - * If [level] is null it resets to the model's default. - * - * @param level One of "low", "medium", "high", or null for default. - */ - fun setReasoningLevel(level: String?) { - _uiState.update { it.copy(reasoningLevel = level) } - val sessionId = runtimeSessionId ?: return - if (level == null) return // null = model default, no need to send WS - viewModelScope.launch(ioDispatcher) { - wsClient.send( - WsMethods.CONFIG_SET, - mapOf( - "key" to "reasoning", - "value" to level, - "session_id" to sessionId, - ), - onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) }, - ) - } - } + fun setReasoningLevel(level: String?) = modelSwitchDelegate.setReasoningLevel(level) fun getModelCapabilities( providerSlug: String, modelName: String, - ): ModelCapabilities? = cachedModelOptions.find { it.slug == providerSlug }?.capabilities?.get(modelName) + ): ModelCapabilities? = modelSwitchDelegate.getModelCapabilities(providerSlug, modelName) - fun getCurrentModelCapabilities(): ModelCapabilities? { - val label = _uiState.value.currentSessionModel ?: return null - val idx = label.indexOf('/') - if (idx <= 0) return null - val provider = label.substring(0, idx) - val model = label.substring(idx + 1) - return getModelCapabilities(provider, model) - } - - private fun syncCurrentModelCapabilities() { - val label = _uiState.value.currentSessionModel - val caps = - if (label == null) { - null - } else { - val idx = label.indexOf('/') - if (idx <= 0) { - null - } else { - val provider = label.substring(0, idx) - val model = label.substring(idx + 1) - cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model) - } - } - _uiState.update { state -> - if (state.currentModelCapabilities != caps) state.copy(currentModelCapabilities = caps) else state - } - } + fun getCurrentModelCapabilities(): ModelCapabilities? = modelSwitchDelegate.getCurrentModelCapabilities() fun switchSession(sessionId: String) { if (sessionId == _uiState.value.currentSessionId) return @@ -3762,332 +3508,17 @@ class ChatViewModel( // ── Approval flow ─────────────────────────────────────────────────── - private fun handleApprovalRequest(event: WsEvent.ApprovalRequest) { - val description = event.description ?: event.command ?: "Unknown command" - val content = "**Approval Required**\n$description" - val msg = - ChatMessage( - role = MessageRole.SYSTEM, - content = content, - approvalInfo = - ApprovalInfo( - command = event.command, - description = event.description, - patternKeys = event.patternKeys, - requestId = event.requestId, - choices = event.choices, - allowPermanent = event.allowPermanent, - smartDenied = event.smartDenied, - ), - ) - _uiState.update { state -> - state.copy( - messages = state.messages + msg, - isAgentTyping = false, - ) - } - // Desktop parity (`prompts.ts` receiveApprovalRequest): ack the render - // so the backend knows this client holds the prompt. Fire-and-forget. - val requestId = event.requestId - val sessionId = runtimeSessionId ?: event.sessionId ?: _uiState.value.currentSessionId - if (requestId != null && sessionId != null) { - viewModelScope.launch(ioDispatcher) { - runCatching { - wsClient.send( - method = WsMethods.APPROVAL_RECEIVED, - params = - mapOf( - "session_id" to sessionId, - "request_id" to requestId, - ), - ) - } - } - } - } - - /** - * Convert a backend approval map (snake_case, from `approval.pending` or - * `session.info` `pending_approval`) into a typed event. Mirrors - * [EventParser] defaults so replay and live paths agree. - */ - private fun parseApprovalMap( - map: Map<*, *>, - sessionId: String?, - ): WsEvent.ApprovalRequest { - @Suppress("UNCHECKED_CAST") - val patternKeys = (map["pattern_keys"] as? List<*>)?.filterIsInstance() - - @Suppress("UNCHECKED_CAST") - val rawChoices = (map["choices"] as? List<*>)?.filterIsInstance() - val allowPermanent = map["allow_permanent"] as? Boolean - val allowSession = map["allow_session"] as? Boolean - val smartDenied = map["smart_denied"] as? Boolean - val choices = - rawChoices ?: run { - if (smartDenied == true) { - listOf("once", "deny") - } else { - buildList { - add("once") - if (allowSession != false) { - add("session") - if (allowPermanent != false) add("always") - } - add("deny") - } - } - } - return WsEvent.ApprovalRequest( - command = map["command"] as? String, - description = map["description"] as? String, - patternKeys = patternKeys, - sessionId = sessionId, - requestId = map["request_id"] as? String, - choices = choices, - allowPermanent = allowPermanent, - smartDenied = smartDenied, - ) - } - - /** - * Reconnect replay (desktop `replayPendingApproval` parity): ask the - * gateway for unresolved approvals and surface the oldest one. Called - * after resume and after each respond (the queue can hold more). - */ - private fun replayPendingApproval(sessionId: String) { - val targetSessionId = runtimeSessionId ?: sessionId - viewModelScope.launch(ioDispatcher) { - wsClient.send( - method = WsMethods.APPROVAL_PENDING, - params = mapOf("session_id" to targetSessionId), - onSent = { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) }, - ) - } - } - - private fun handleApprovalPendingResult(result: Any?) { - @Suppress("UNCHECKED_CAST") - val approvals = (result as? Map<*, *>)?.get("approvals") as? List<*> - val first = approvals?.filterIsInstance>()?.firstOrNull() ?: return - val requestId = first["request_id"] as? String ?: return - val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId - // Don't duplicate a prompt already on screen. - val alreadyShown = - _uiState.value.messages.any { it.approvalInfo?.requestId == requestId } - if (alreadyShown) return - handleApprovalRequest(parseApprovalMap(first, sessionId)) - } - - fun respondToApproval(action: String) { - val state = _uiState.value - val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return - val sessionId = runtimeSessionId ?: state.currentSessionId ?: return - // Desktop sends `once` for a single run; legacy mobile sent `approve` - // (any non-deny still unblocks, but stay on-spec going forward). - val choice = if (action == "approve") "once" else action - val requestId = approvalMsg.approvalInfo?.requestId - - // Clear buttons immediately - _uiState.update { s -> - s.copy( - messages = - s.messages.map { - if (it.id == approvalMsg.id) { - it.copy(approvalInfo = null) - } else { - it - } - }, - ) - } - - viewModelScope.launch(ioDispatcher) { - val params = - mutableMapOf( - "session_id" to sessionId, - "choice" to choice, - "all" to false, - ) - if (requestId != null) params["request_id"] = requestId - wsClient.send( - method = WsMethods.APPROVAL_RESPOND, - params = params, - onSent = { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) }, - ) - // The queue can hold more pendings — surface the next one. - replayPendingApproval(sessionId) - } - } + fun respondToApproval(action: String) = approvalsDelegate.respondToApproval(action) // ── Sudo / secret prompt flow (issue #524) ────────────────────────── - /** - * The agent needs the user's sudo password. Previously dropped → agent - * hung forever. Now we surface a secure dialog and reply via sudo.respond. - */ - private fun handleSudoRequest(event: WsEvent.SudoRequest) { - _uiState.update { - it.copy( - sudoPrompt = SudoPromptUi(event.requestId, event.sessionId), - isAgentTyping = false, - ) - } - } + fun dismissSudo() = credentialPromptsDelegate.dismissSudo() - /** - * Backend sudo timeout (120s) — clear only the matching dialog so a - * late expire for an old prompt never kills the current one. - * Desktop parity: `patchOverlayState(prev => prev.sudo?.requestId === id ? null : prev)`. - */ - private fun handleSudoExpire(event: WsEvent.SudoExpire) { - _uiState.update { state -> - val current = state.sudoPrompt ?: return@update state - if (event.requestId != null && current.requestId != null && - event.requestId != current.requestId - ) { - return@update state - } - state.copy(sudoPrompt = null) - } - } + fun dismissSecret() = credentialPromptsDelegate.dismissSecret() - /** - * The agent needs a secret value (token/password). Previously dropped → - * agent hung forever. Now we surface a secure dialog and reply via - * secret.respond. - */ - private fun handleSecretRequest(event: WsEvent.SecretRequest) { - _uiState.update { - it.copy( - secretPrompt = - SecretPromptUi( - event.requestId, - event.sessionId, - event.envVar, - event.prompt, - ), - isAgentTyping = false, - ) - } - } + fun respondToSudo(password: String) = credentialPromptsDelegate.respondToSudo(password) - /** - * Backend secret timeout — match-only clear like [handleSudoExpire]. - */ - private fun handleSecretExpire(event: WsEvent.SecretExpire) { - _uiState.update { state -> - val current = state.secretPrompt ?: return@update state - if (event.requestId != null && current.requestId != null && - event.requestId != current.requestId - ) { - return@update state - } - state.copy(secretPrompt = null) - } - } - - /** - * Cancel → send empty password (desktop parity: - * `prompt-overlays.tsx` `send('')`). Backend treats empty sudo as - * failed sudo (no command runs), so closing the dialog is a safe - * refusal that unblocks the turn instantly instead of hanging 120s - * until `sudo.expire`. - */ - fun dismissSudo() { - val prompt = _uiState.value.sudoPrompt ?: return - val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId - _uiState.update { it.copy(sudoPrompt = null) } - if (sessionId == null) return - viewModelScope.launch(ioDispatcher) { - val params = - mutableMapOf( - "session_id" to sessionId, - "password" to "", - ) - prompt.requestId?.let { id -> params["request_id"] = id } - wsClient.send( - method = WsMethods.SUDO_RESPOND, - params = params, - onSent = { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }, - ) - } - } - - /** - * Cancel → send empty value (desktop parity). Backend `secret_cb` - * returns `skipped=True` on empty, unblocking the turn instantly. - */ - fun dismissSecret() { - val prompt = _uiState.value.secretPrompt ?: return - val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId - _uiState.update { it.copy(secretPrompt = null) } - if (sessionId == null) return - viewModelScope.launch(ioDispatcher) { - val params = - mutableMapOf( - "session_id" to sessionId, - "value" to "", - ) - prompt.requestId?.let { id -> params["request_id"] = id } - wsClient.send( - method = WsMethods.SECRET_RESPOND, - params = params, - onSent = { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }, - ) - } - } - - /** - * Send the user's sudo password back to the gateway. Mirrors - * respondToApproval: clear the prompt immediately, then fire the RPC. - */ - fun respondToSudo(password: String) { - val prompt = _uiState.value.sudoPrompt ?: return - val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId ?: return - if (password.isBlank()) return - - _uiState.update { it.copy(sudoPrompt = null) } - - viewModelScope.launch(ioDispatcher) { - val params = - mutableMapOf( - "session_id" to sessionId, - "password" to password, - ) - prompt.requestId?.let { id -> params["request_id"] = id } - wsClient.send( - method = WsMethods.SUDO_RESPOND, - params = params, - onSent = { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }, - ) - } - } - - /** - * Send the user's secret value back to the gateway. Mirrors respondToSudo. - */ - fun respondToSecret(value: String) { - val prompt = _uiState.value.secretPrompt ?: return - val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId ?: return - if (value.isBlank()) return - - _uiState.update { it.copy(secretPrompt = null) } - - viewModelScope.launch(ioDispatcher) { - val params = - mutableMapOf( - "session_id" to sessionId, - "value" to value, - ) - prompt.requestId?.let { id -> params["request_id"] = id } - wsClient.send( - method = WsMethods.SECRET_RESPOND, - params = params, - onSent = { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }, - ) - } - } + fun respondToSecret(value: String) = credentialPromptsDelegate.respondToSecret(value) fun reconnect() { _uiState.update { @@ -4111,95 +3542,18 @@ class ChatViewModel( password: String, onResult: (Boolean, String?) -> Unit, ) { - viewModelScope.launch(ioDispatcher) { - val endpoint = AuthManager.endpointForBuild() - val jsonMediaType = "application/json; charset=utf-8".toMediaType() - val jsonBody = - JSONObject() - .put("provider", "basic") - .put("username", username) - .put("password", password) - .put("next", "") - .toString() - - try { - val loginClient = - com.m57.hermescontrol.data.remote.OkHttpProvider.probe - .newBuilder() - .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) - .build() - - val loginReq = - Request - .Builder() - .url(endpoint.resolve("auth/password-login").toString()) - .header("Content-Type", "application/json") - .post(jsonBody.toRequestBody(jsonMediaType)) - .build() - loginClient.newCall(loginReq).execute().use { loginResp -> - if (!loginResp.isSuccessful) { - val msg = - when (loginResp.code) { - 401 -> "Invalid username or password (401)" - 403 -> "Forbidden (403)" - else -> "HTTP error code: ${loginResp.code}" - } - withContext(Dispatchers.Main) { - onResult(false, msg) - } - return@launch - } - } - - val ticketClient = - com.m57.hermescontrol.data.remote.OkHttpProvider.base - .newBuilder() - .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) - .build() - - val ticketReq = - Request - .Builder() - .url(endpoint.resolve("api/auth/ws-ticket").toString()) - .post("{}".toRequestBody(jsonMediaType)) - .build() - ticketClient.newCall(ticketReq).execute().use { ticketResp -> - if (!ticketResp.isSuccessful) { - withContext(Dispatchers.Main) { - onResult(false, "Failed to mint WS ticket: HTTP ${ticketResp.code}") - } - return@launch - } - - val body = ticketResp.body.string() - val ticket = JSONObject(body).optString("ticket").takeIf { it.isNotBlank() } - - if (ticket.isNullOrBlank()) { - withContext(Dispatchers.Main) { - onResult(false, "Invalid ticket returned from server") - } - return@launch - } - - AuthManager.setWsAuthParam("ticket") - AuthManager.setToken(ticket) - - withContext(Dispatchers.Main) { - onResult(true, null) - reconnect() - } - } - } catch (e: java.io.IOException) { - withContext(Dispatchers.Main) { - onResult(false, "Connection failed: ${e.message}") - } - } catch (e: org.json.JSONException) { - withContext(Dispatchers.Main) { - onResult(false, "Connection failed: ${e.message}") - } - } + viewModelScope.launch { + reloginAuthenticator.relogin( + username = username, + password = password, + onSuccess = { + onResult(true, null) + reconnect() + }, + onFailure = { msg -> + onResult(false, msg) + }, + ) } } From 7b198fa6d61a9ab1b541a6ea5b949ff772082fa3 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 00:21:06 +0300 Subject: [PATCH 11/42] fix(chat): restore pending approval guard, tighten encapsulation, drop dormant tracker --- .../ui/chat/ChatApprovalsDelegate.kt | 6 +- .../ui/chat/ChatModelSwitchDelegate.kt | 10 +- .../ui/chat/ChatRpcRequestTracker.kt | 111 ------------------ .../hermescontrol/ui/chat/ChatViewModel.kt | 5 - .../ui/chat/ChatApprovalsDelegateTest.kt | 30 +++++ .../ui/chat/ChatRpcRequestTrackerTest.kt | 94 --------------- 6 files changed, 40 insertions(+), 216 deletions(-) delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTracker.kt delete mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTrackerTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt index 99c3094c..0144d71b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt @@ -81,8 +81,12 @@ class ChatApprovalsDelegate( @Suppress("UNCHECKED_CAST") val approvals = (result as? Map<*, *>)?.get("approvals") as? List<*> val first = approvals?.filterIsInstance>()?.firstOrNull() ?: return + val requestId = first["request_id"] as? String ?: return val sessionId = runtimeSessionId() ?: uiState.value.currentSessionId - maybeSurfacePendingApproval(first, sessionId) + val alreadyShown = + uiState.value.messages.any { it.approvalInfo?.requestId == requestId } + if (alreadyShown) return + handleApprovalRequest(parseApprovalMap(first, sessionId)) } fun handleApprovalRespondResult(result: Any?) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt index 44e2996c..b295f6ef 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -19,7 +19,7 @@ import java.util.concurrent.ConcurrentHashMap * Tracks an in-flight `config.set key=model` RPC and the model it would replace * so confirmation dialog dismiss can roll back optimistically. */ -data class ActiveModelSwitch( +private data class ActiveModelSwitch( val spec: String, val previousModel: String?, ) @@ -47,10 +47,10 @@ class ChatModelSwitchDelegate( private val getPinnedModels: () -> List = { AuthManager.getPinnedModels() }, private val savePinnedModels: (List) -> Unit = { AuthManager.savePinnedModels(it) }, ) { - val pendingModelSwitchRequests = ConcurrentHashMap() - var activeModelSwitchConfirmation: ActiveModelSwitch? = null - var optimisticPreviousModel: String? = null - var cachedModelOptions: List = emptyList() + private val pendingModelSwitchRequests = ConcurrentHashMap() + private var activeModelSwitchConfirmation: ActiveModelSwitch? = null + private var optimisticPreviousModel: String? = null + private var cachedModelOptions: List = emptyList() fun isModelPickerCommand(command: String): Boolean { val trimmed = command.trim() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTracker.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTracker.kt deleted file mode 100644 index 8492ab54..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTracker.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import java.util.concurrent.ConcurrentHashMap - -/** - * Tracks an in-flight RPC request associated with a specific session generation - * and resume sequence to guard against stale results/errors from previous sessions. - */ -data class TrackedSessionRequest( - val generation: Long, - val resumeSequence: Long = 0L, - val sessionId: String? = null, -) - -/** - * Owns RPC id-to-method mapping, session request generations, hydration sequences, - * and staleness detection. - * - * Extracted behavior-preservingly from [ChatViewModel] to ensure deterministic - * RPC tracking across session creation, switches, resumes, and reconnects. - */ -class ChatRpcRequestTracker( - private val getCurrentSessionId: () -> String?, -) { - /** Maps an in-flight RPC id to its method for UI error labeling. */ - private val idToMethod = ConcurrentHashMap() - private val sessionRequestById = ConcurrentHashMap() - - var sessionGeneration: Long = 0L - private set - - var resumeRequestSequence: Long = 0L - private set - var activeResumeRequestSequence: Long = 0L - private set - - var hydrationRequestSequence: Long = 0L - private set - var activeHydrationRequestSequence: Long = 0L - private set - - var resumedGeneration: Long = -1L - var hydratedGeneration: Long = -1L - - fun nextSessionGeneration(): Long { - sessionGeneration++ - return sessionGeneration - } - - fun nextResumeSequence(): Long { - resumeRequestSequence++ - activeResumeRequestSequence = resumeRequestSequence - return resumeRequestSequence - } - - fun nextHydrationSequence(): Long { - hydrationRequestSequence++ - activeHydrationRequestSequence = hydrationRequestSequence - return hydrationRequestSequence - } - - fun trackRequest( - id: String, - method: String, - ) { - idToMethod[id] = method - } - - fun trackSessionRequest( - id: String, - method: String, - generation: Long, - resumeSequence: Long = 0L, - sessionId: String? = null, - ) { - sessionRequestById[id] = TrackedSessionRequest(generation, resumeSequence, sessionId) - trackRequest(id, method) - } - - fun isCurrentSessionRequest( - sessionId: String, - generation: Long, - ): Boolean = generation == sessionGeneration && sessionId == getCurrentSessionId() - - fun isCurrentHydration( - sessionId: String, - generation: Long, - requestSequence: Long, - ): Boolean = requestSequence == activeHydrationRequestSequence && isCurrentSessionRequest(sessionId, generation) - - fun isStaleSessionRequest(id: String): Boolean = sessionRequestById[id]?.let(::isStaleSessionRequest) == true - - fun isStaleSessionRequest(request: TrackedSessionRequest): Boolean = - request.generation != sessionGeneration || - (request.sessionId != null && request.sessionId != getCurrentSessionId()) || - (request.resumeSequence != 0L && request.resumeSequence != activeResumeRequestSequence) - - fun removeMethod(id: String): String? = idToMethod.remove(id) - - fun removeSessionRequest(id: String): TrackedSessionRequest? = sessionRequestById.remove(id) - - fun forgetRequest(id: String) { - idToMethod.remove(id) - sessionRequestById.remove(id) - } - - fun resetSessionStateTracking() { - resumedGeneration = -1L - hydratedGeneration = -1L - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index bdf18ffa..828a53a9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -510,11 +510,6 @@ class ChatViewModel( private val sessionRequestById = ConcurrentHashMap() private var sessionGeneration = 0L - private data class ActiveModelSwitch( - val spec: String, - val previousModel: String?, - ) - private var resumeRequestSequence = 0L private var activeResumeRequestSequence = 0L private var hydrationRequestSequence = 0L diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt index 4f3d298d..6623c4bf 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt @@ -161,6 +161,36 @@ class ChatApprovalsDelegateTest { ) } + @Test + fun handleApprovalPendingResult_dropsMissingRequestId_andDedupesAlreadyShown() { + val initialCount = uiState.value.messages.size + + // No request_id -> dropped entirely + delegate.handleApprovalPendingResult( + mapOf("approvals" to listOf(mapOf("command" to "rm -rf /"))), + ) + assertEquals(initialCount, uiState.value.messages.size) + + // Valid request_id -> surfaced + delegate.handleApprovalPendingResult( + mapOf("approvals" to listOf(mapOf("command" to "git pull", "request_id" to "r-valid"))), + ) + assertEquals(initialCount + 1, uiState.value.messages.size) + assertEquals( + "r-valid", + uiState.value.messages + .last() + .approvalInfo + ?.requestId, + ) + + // Duplicate request_id -> ignored + delegate.handleApprovalPendingResult( + mapOf("approvals" to listOf(mapOf("command" to "git pull", "request_id" to "r-valid"))), + ) + assertEquals(initialCount + 1, uiState.value.messages.size) + } + @Test fun handleApprovalRespondResult_appendsDesktopParitySystemMessage() { delegate.handleApprovalRespondResult(mapOf("resolved" to 1)) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTrackerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTrackerTest.kt deleted file mode 100644 index 78eac772..00000000 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTrackerTest.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class ChatRpcRequestTrackerTest { - private var currentSessionId: String? = "session-a" - private val tracker = ChatRpcRequestTracker { currentSessionId } - - @Test - fun trackRequest_andRemoveMethod() { - tracker.trackRequest("req-1", "session.create") - assertEquals("session.create", tracker.removeMethod("req-1")) - assertNull(tracker.removeMethod("req-1")) - } - - @Test - fun generationIncrement_detectsStaleGenerations() { - val gen1 = tracker.nextSessionGeneration() - assertEquals(1L, gen1) - - tracker.trackSessionRequest( - id = "req-resume-1", - method = "session.resume", - generation = gen1, - sessionId = "session-a", - ) - assertFalse(tracker.isStaleSessionRequest("req-resume-1")) - - val gen2 = tracker.nextSessionGeneration() - assertEquals(2L, gen2) - assertTrue(tracker.isStaleSessionRequest("req-resume-1")) - } - - @Test - fun sessionMismatch_marksRequestStale() { - val gen = tracker.nextSessionGeneration() - tracker.trackSessionRequest( - id = "req-2", - method = "session.resume", - generation = gen, - sessionId = "session-a", - ) - assertFalse(tracker.isStaleSessionRequest("req-2")) - - currentSessionId = "session-b" - assertTrue(tracker.isStaleSessionRequest("req-2")) - } - - @Test - fun resumeSequence_marksSupersededResumeStale() { - val gen = tracker.nextSessionGeneration() - val seq1 = tracker.nextResumeSequence() - tracker.trackSessionRequest( - id = "req-resume-seq1", - method = "session.resume", - generation = gen, - resumeSequence = seq1, - sessionId = "session-a", - ) - assertFalse(tracker.isStaleSessionRequest("req-resume-seq1")) - - val seq2 = tracker.nextResumeSequence() - assertEquals(2L, seq2) - assertTrue(tracker.isStaleSessionRequest("req-resume-seq1")) - } - - @Test - fun hydrationSequence_guardsActiveHydration() { - val gen = tracker.nextSessionGeneration() - val hyd1 = tracker.nextHydrationSequence() - assertTrue(tracker.isCurrentHydration("session-a", gen, hyd1)) - - val hyd2 = tracker.nextHydrationSequence() - assertFalse(tracker.isCurrentHydration("session-a", gen, hyd1)) - assertTrue(tracker.isCurrentHydration("session-a", gen, hyd2)) - } - - @Test - fun forgetRequest_removesBothTrackingEntries() { - tracker.trackSessionRequest( - id = "req-forget", - method = "session.resume", - generation = tracker.sessionGeneration, - sessionId = "session-a", - ) - tracker.forgetRequest("req-forget") - assertNull(tracker.removeMethod("req-forget")) - assertNull(tracker.removeSessionRequest("req-forget")) - } -} From 4287723ab0c497d70c90d87c1584c6757ee39727 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 17:45:33 +0300 Subject: [PATCH 12/42] refactor(skills): reconcile duplicate components and drop dead category filter Use extracted components/SkillEditorDialog and SourceBadge. Drop unused CategoryFilter enum. --- .../hermescontrol/ui/skills/SkillsScreen.kt | 166 +----------------- .../ui/skills/SkillsViewModel.kt | 4 - 2 files changed, 2 insertions(+), 168 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index 070c4110..b1f06cf2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -89,6 +89,8 @@ import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.skills.components.SkillEditorDialog +import com.m57.hermescontrol.ui.skills.components.SourceBadge internal const val CATEGORY_ALL = "All" @@ -515,29 +517,6 @@ private fun SkillCard( } } } -// ── Source Badge ─────────────────────────────────────────────────────── - -@Composable -private fun SourceBadge(source: String) { - val (color, label) = - when (source) { - "hub" -> MaterialTheme.colorScheme.tertiary to "hub" - "built-in" -> MaterialTheme.colorScheme.secondary to "built-in" - "optional" -> MaterialTheme.colorScheme.outline to "opt" - else -> MaterialTheme.colorScheme.outline to source - } - Surface( - color = color.copy(alpha = 0.15f), - shape = MaterialTheme.shapes.small, - ) { - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = color, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) - } -} // ── Hub Browse View ───────────────────────────────────────────────────── @@ -1270,144 +1249,3 @@ fun SkillPreviewDialog( } } } - -// ── Skill Editor Dialog (existing, preserved unchanged) ──────────────── - -@Composable -fun SkillEditorDialog( - skillName: String, - isLoading: Boolean, - initialContent: String?, - isSaving: Boolean, - saveSuccess: Boolean, - onSave: (String) -> Unit, - onDismiss: () -> Unit, - onClearSaveSuccess: () -> Unit, -) { - var contentText by remember(initialContent) { mutableStateOf(initialContent.orEmpty()) } - var showDiscardConfirm by remember { mutableStateOf(false) } - - val hasChanges = - remember(initialContent, contentText) { - val original = initialContent.orEmpty() - original != contentText - } - - LaunchedEffect(saveSuccess) { - if (saveSuccess) { - onClearSaveSuccess() - onDismiss() - } - } - - Dialog( - onDismissRequest = { - if (hasChanges) { - showDiscardConfirm = true - } else { - onDismiss() - } - }, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Card( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) { - HermesScaffold( - title = { Text(stringResource(R.string.skills_edit_title, skillName)) }, - navigationIcon = - NavIcon.Back( - onBack = { - if (hasChanges) { - showDiscardConfirm = true - } else { - onDismiss() - } - }, - ), - actions = { - if (!isLoading) { - IconButton( - onClick = { onSave(contentText) }, - enabled = !isSaving, - ) { - if (isSaving) { - CircularProgressIndicator( - modifier = Modifier.fillMaxSize(0.6f), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurface, - ) - } else { - Icon( - imageVector = Icons.Filled.Save, - contentDescription = stringResource(R.string.content_desc_save_changes), - ) - } - } - } - }, - ) { padding -> - Box( - modifier = - Modifier - .fillMaxSize(), - ) { - when { - isLoading -> { - LoadingState() - } - - else -> { - OutlinedTextField( - value = contentText, - onValueChange = { contentText = it }, - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - textStyle = - TextStyle( - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface, - ), - placeholder = { - Text(stringResource(R.string.skills_content_placeholder)) - }, - ) - } - } - } - } - } - } - - if (showDiscardConfirm) { - AlertDialog( - onDismissRequest = { showDiscardConfirm = false }, - title = { Text(stringResource(R.string.skills_discard_title)) }, - text = { Text(stringResource(R.string.skills_discard_message)) }, - confirmButton = { - TextButton( - onClick = { - showDiscardConfirm = false - onDismiss() - }, - ) { - Text(stringResource(R.string.action_discard)) - } - }, - dismissButton = { - TextButton(onClick = { showDiscardConfirm = false }) { - Text(stringResource(R.string.action_cancel)) - } - }, - ) - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt index 40dad9e7..a7dc2215 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt @@ -39,10 +39,6 @@ enum class SkillFilter( DISABLED(R.string.skills_status_disabled), } -enum class CategoryFilter { - ALL, -} - data class SkillsUiState( val isLoading: Boolean = false, val skills: List = emptyList(), From f0c2d2f87b58a16b55c50192d3e765947a320a55 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 17:48:13 +0300 Subject: [PATCH 13/42] refactor(util): extract FormatUtils and deduplicate byte formatting Consolidate formatBytes and formatDuration into util/FormatUtils. Use in SystemScreen and MemoryScreen, backed by unit tests. --- .../hermescontrol/ui/memory/MemoryScreen.kt | 8 +-- .../hermescontrol/ui/system/SystemScreen.kt | 23 +------- .../com/m57/hermescontrol/util/FormatUtils.kt | 28 ++++++++++ .../m57/hermescontrol/util/FormatUtilsTest.kt | 54 +++++++++++++++++++ 4 files changed, 85 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/util/FormatUtils.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/util/FormatUtilsTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt index b85fa06b..a8d9d5ce 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt @@ -62,6 +62,7 @@ import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.plugins.memoryProviderStatusLabel import com.m57.hermescontrol.ui.plugins.memoryProviderStatusType +import com.m57.hermescontrol.util.formatBytes /** * Memory management home — owns the memory surface that used to live in the @@ -425,10 +426,3 @@ private fun MemoryProviderRow( } } } - -private fun formatBytes(bytes: Long): String = - when { - bytes >= 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) - bytes >= 1024 -> "%.1f KB".format(bytes / 1024.0) - else -> "$bytes B" - } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index 523f862d..e8a36ea1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -96,31 +96,12 @@ import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.system.components.CredentialEntryRow import com.m57.hermescontrol.ui.system.components.HookCard +import com.m57.hermescontrol.util.formatBytes +import com.m57.hermescontrol.util.formatDuration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -// ── Helpers ───────────────────────────────────────────────────────────── - -private fun formatBytes(bytes: Long): String = - when { - bytes < 1024 -> "$bytes B" - bytes < 1024 * 1024 -> "${bytes / 1024} KB" - bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB" - else -> "${"%.1f".format(bytes.toDouble() / (1024 * 1024 * 1024))} GB" - } - -private fun formatDuration(totalSeconds: Double): String { - val days = (totalSeconds / 86400).toInt() - val hours = ((totalSeconds % 86400) / 3600).toInt() - val minutes = ((totalSeconds % 3600) / 60).toInt() - return buildString { - if (days > 0) append("${days}d ") - if (hours > 0 || days > 0) append("${hours}h ") - append("${minutes}m") - } -} - @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable fun SystemScreen( diff --git a/app/src/main/java/com/m57/hermescontrol/util/FormatUtils.kt b/app/src/main/java/com/m57/hermescontrol/util/FormatUtils.kt new file mode 100644 index 00000000..37cc926f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/util/FormatUtils.kt @@ -0,0 +1,28 @@ +package com.m57.hermescontrol.util + +import java.util.Locale + +/** + * Formats a byte count into human-readable B, KB, MB, or GB. + */ +fun formatBytes(bytes: Long): String = + when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB" + else -> String.format(Locale.US, "%.1f GB", bytes.toDouble() / (1024 * 1024 * 1024)) + } + +/** + * Formats seconds into a human-readable duration (e.g. "2d 4h 15m"). + */ +fun formatDuration(totalSeconds: Double): String { + val days = (totalSeconds / 86400).toInt() + val hours = ((totalSeconds % 86400) / 3600).toInt() + val minutes = ((totalSeconds % 3600) / 60).toInt() + return buildString { + if (days > 0) append("${days}d ") + if (hours > 0 || days > 0) append("${hours}h ") + append("${minutes}m") + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/util/FormatUtilsTest.kt b/app/src/test/java/com/m57/hermescontrol/util/FormatUtilsTest.kt new file mode 100644 index 00000000..1e2297a5 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/util/FormatUtilsTest.kt @@ -0,0 +1,54 @@ +package com.m57.hermescontrol.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FormatUtilsTest { + @Test + fun testFormatBytes_bytes() { + assertEquals("0 B", formatBytes(0L)) + assertEquals("500 B", formatBytes(500L)) + assertEquals("1023 B", formatBytes(1023L)) + } + + @Test + fun testFormatBytes_kilobytes() { + assertEquals("1 KB", formatBytes(1024L)) + assertEquals("2 KB", formatBytes(2048L)) + assertEquals("1023 KB", formatBytes(1024 * 1024 - 1L)) + } + + @Test + fun testFormatBytes_megabytes() { + assertEquals("1 MB", formatBytes(1024 * 1024L)) + assertEquals("500 MB", formatBytes(500 * 1024 * 1024L)) + } + + @Test + fun testFormatBytes_gigabytes() { + assertEquals("1.0 GB", formatBytes(1024 * 1024 * 1024L)) + assertEquals("2.5 GB", formatBytes((2.5 * 1024 * 1024 * 1024).toLong())) + } + + @Test + fun testFormatDuration_minutes() { + assertEquals("0m", formatDuration(0.0)) + assertEquals("0m", formatDuration(45.0)) + assertEquals("1m", formatDuration(60.0)) + assertEquals("59m", formatDuration(3540.0)) + } + + @Test + fun testFormatDuration_hoursAndMinutes() { + assertEquals("1h 0m", formatDuration(3600.0)) + assertEquals("1h 1m", formatDuration(3660.0)) + assertEquals("23h 59m", formatDuration(86399.0)) + } + + @Test + fun testFormatDuration_daysHoursMinutes() { + assertEquals("1d 0h 0m", formatDuration(86400.0)) + assertEquals("1d 1h 1m", formatDuration(90060.0)) + assertEquals("3d 5h 30m", formatDuration(3 * 86400.0 + 5 * 3600.0 + 30 * 60.0)) + } +} From 6876db039335bfbef91caae53ff23aad89441715 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 17:53:02 +0300 Subject: [PATCH 14/42] refactor(sessions): extract search formatting and paging logic Extract SessionSearchFormatting and SessionsPaging helpers. Deduplicate paging calculation in SessionsViewModel and add unit tests. --- .../ui/sessions/SessionSearchFormatting.kt | 98 +++++++++++++++++++ .../ui/sessions/SessionsPaging.kt | 42 ++++++++ .../ui/sessions/SessionsScreen.kt | 94 ------------------ .../ui/sessions/SessionsViewModel.kt | 38 +++++-- .../sessions/SessionSearchFormattingTest.kt | 72 ++++++++++++++ .../ui/sessions/SessionsPagingTest.kt | 75 ++++++++++++++ .../ui/sessions/SessionsViewModelTest.kt | 8 -- 7 files changed, 315 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsPaging.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsPagingTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt new file mode 100644 index 00000000..1f3d4e41 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormatting.kt @@ -0,0 +1,98 @@ +package com.m57.hermescontrol.ui.sessions + +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionSearchResult +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import kotlin.math.abs + +/** + * Converts a backend search hit into a display model WITHOUT faking a title. + * The backend returns no session name, only a matched `snippet` + metadata, so the + * card must render the snippet as a match excerpt (never as the title). `title` is + * deliberately left null so [SearchResultCard] shows the honest "Match" layout. + */ +internal fun SessionSearchResult.toSessionInfo(): SessionInfo = + SessionInfo( + id = session_id, + title = null, + preview = snippet, + source = source, + model = model, + started_at = session_started, + // message_count/status aren't in the search payload; leave null so the + // search card hides those normal-list affordances. + ) + +/** + * Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute + * string. Used for search results where the session name is unknown. + */ +internal fun formatPlayedAt(epochSeconds: Double?): String? { + if (epochSeconds == null || epochSeconds <= 0.0) return null + val instant = + try { + Instant.ofEpochSecond(epochSeconds.toLong()) + } catch (_: Exception) { + return null + } + val now = Instant.now() + val diffSec = abs(now.epochSecond - instant.epochSecond) + val absFormatter = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.MEDIUM) + .withZone(ZoneId.systemDefault()) + return when { + diffSec < 60 -> "just now" + diffSec < 3600 -> "${diffSec / 60}m ago" + diffSec < 86400 -> "${diffSec / 3600}h ago" + diffSec < 7 * 86400 -> "${diffSec / 86400}d ago" + else -> absFormatter.format(instant) + } +} + +private val searchSnippetJson = Json { isLenient = true } + +/** + * Makes a backend FTS snippet safe to present as prose. Some indexed messages are stored as + * JSON, so prefer their human-readable text fields rather than rendering the entire payload. + */ +fun cleanSearchSnippet(snippet: String): String { + val withoutHighlightMarkers = snippet.replace(">>>", "").replace("<<<", "").trim() + val parsedSnippet = + runCatching { searchSnippetJson.parseToJsonElement(withoutHighlightMarkers) }.getOrNull() + val displayText = parsedSnippet?.searchText() ?: withoutHighlightMarkers + return displayText + .replace(Regex("\\s+"), " ") + .trim() +} + +private fun JsonElement.searchText(): String? = + when (this) { + is JsonPrimitive -> { + contentOrNull?.takeIf(String::isNotBlank) + } + + is JsonArray -> { + mapNotNull { it.searchText() } + .joinToString(" ") + .takeIf(String::isNotBlank) + } + + is JsonObject -> { + val textFieldNames = + listOf("content", "text", "message", "body", "prompt", "summary", "title") + val wrapperFieldNames = listOf("data", "result", "payload", "parts") + textFieldNames + .firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() } + ?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() } + } + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsPaging.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsPaging.kt new file mode 100644 index 00000000..7467fd79 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsPaging.kt @@ -0,0 +1,42 @@ +package com.m57.hermescontrol.ui.sessions + +data class SessionPaginationResolution( + val hasMore: Boolean, + val total: Int, +) + +object SessionsPaging { + /** + * Computes hasMore and effective total when refreshing or loading the initial page of sessions. + */ + fun resolveInitialPaging( + receivedCount: Int, + pageSize: Int, + backendTotal: Int, + accumulatedCount: Int, + ): SessionPaginationResolution { + val hasMore = receivedCount >= pageSize && backendTotal > accumulatedCount + return SessionPaginationResolution( + hasMore = hasMore, + total = if (hasMore) backendTotal else accumulatedCount, + ) + } + + /** + * Computes hasMore and effective total when appending a page of sessions. + */ + fun resolveLoadMorePaging( + receivedCount: Int, + pageSize: Int, + addedNewItems: Boolean, + backendTotal: Int, + accumulatedCount: Int, + ): SessionPaginationResolution { + val receivedFullPage = receivedCount >= pageSize + val hasMore = receivedFullPage && addedNewItems && backendTotal > accumulatedCount + return SessionPaginationResolution( + hasMore = hasMore, + total = if (hasMore) backendTotal else accumulatedCount, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 0e400ca6..bd912149 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -116,17 +116,6 @@ import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listItemSpacing -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import kotlin.math.abs /** * Auto-load the next history page when the user scrolls to within this many @@ -197,89 +186,6 @@ private fun highlightText( } } -/** - * Converts a backend search hit into a display model WITHOUT faking a title. - * The backend returns no session name, only a matched `snippet` + metadata, so the - * card must render the snippet as a match excerpt (never as the title). `title` is - * deliberately left null so [SearchResultCard] shows the honest "Match" layout. - */ -private fun SessionSearchResult.toSessionInfo(): SessionInfo = - SessionInfo( - id = session_id, - title = null, - preview = snippet, - source = source, - model = model, - started_at = session_started, - // message_count/status aren't in the search payload; leave null so the - // search card hides those normal-list affordances. - ) - -/** - * Formats a backend epoch-seconds timestamp into a friendly, local relative/absolute - * string. Used for search results where the session name is unknown. - */ -private fun formatPlayedAt(epochSeconds: Double?): String? { - if (epochSeconds == null || epochSeconds <= 0.0) return null - val instant = - try { - Instant.ofEpochSecond(epochSeconds.toLong()) - } catch (_: Exception) { - return null - } - val now = Instant.now() - val diffSec = abs(now.epochSecond - instant.epochSecond) - val absFormatter = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.MEDIUM) - .withZone(ZoneId.systemDefault()) - return when { - diffSec < 60 -> "just now" - diffSec < 3600 -> "${diffSec / 60}m ago" - diffSec < 86400 -> "${diffSec / 3600}h ago" - diffSec < 7 * 86400 -> "${diffSec / 86400}d ago" - else -> absFormatter.format(instant) - } -} - -private val searchSnippetJson = Json { isLenient = true } - -/** - * Makes a backend FTS snippet safe to present as prose. Some indexed messages are stored as - * JSON, so prefer their human-readable text fields rather than rendering the entire payload. - */ -internal fun cleanSearchSnippet(snippet: String): String { - val withoutHighlightMarkers = snippet.replace(">>>", "").replace("<<<", "").trim() - val parsedSnippet = - runCatching { searchSnippetJson.parseToJsonElement(withoutHighlightMarkers) }.getOrNull() - val displayText = parsedSnippet?.searchText() ?: withoutHighlightMarkers - return displayText - .replace(Regex("\\s+"), " ") - .trim() -} - -private fun JsonElement.searchText(): String? = - when (this) { - is JsonPrimitive -> { - contentOrNull?.takeIf(String::isNotBlank) - } - - is JsonArray -> { - mapNotNull { it.searchText() } - .joinToString(" ") - .takeIf(String::isNotBlank) - } - - is JsonObject -> { - val textFieldNames = - listOf("content", "text", "message", "body", "prompt", "summary", "title") - val wrapperFieldNames = listOf("data", "result", "payload", "parts") - textFieldNames - .firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() } - ?: wrapperFieldNames.firstNotNullOfOrNull { fieldName -> this[fieldName]?.searchText() } - } - } - private fun displayedSessions(state: SessionsUiState): List = if (state.isSearchMode) { state.searchResults.map { searchResult -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 26da29e3..0dc6f2b9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -147,11 +147,17 @@ class SessionsViewModel : rawPaginationOffset = data.nextOffset(0) _uiState.update { val newSessions = data.sessions.orEmpty().pinnedFirst() - val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size + val paging = + SessionsPaging.resolveInitialPaging( + receivedCount = data.sessions.size, + pageSize = PAGE_SIZE, + backendTotal = data.total, + accumulatedCount = newSessions.size, + ) it.copy( sessions = newSessions, - total = if (hasMore) data.total else newSessions.size, - hasMore = hasMore, + total = paging.total, + hasMore = paging.hasMore, ) } } @@ -229,14 +235,20 @@ class SessionsViewModel : if (requestGeneration != generation) return@safeLaunchLoad rawPaginationOffset = data.nextOffset(0) val sessionsList = data.sessions.orEmpty().pinnedFirst() - val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size + val paging = + SessionsPaging.resolveInitialPaging( + receivedCount = data.sessions.size, + pageSize = PAGE_SIZE, + backendTotal = data.total, + accumulatedCount = sessionsList.size, + ) _uiState.update { it.copy( isLoading = false, isLoadingMore = false, sessions = sessionsList, - total = if (hasMore) data.total else sessionsList.size, - hasMore = hasMore, + total = paging.total, + hasMore = paging.hasMore, selectedIds = emptySet(), ) } @@ -284,14 +296,20 @@ class SessionsViewModel : (it.sessions + data.sessions) .distinctBy { s -> s.id } .pinnedFirst() - val receivedFullPage = data.sessions.size >= PAGE_SIZE val addedNewItems = newSessions.size > it.sessions.size - val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size + val paging = + SessionsPaging.resolveLoadMorePaging( + receivedCount = data.sessions.size, + pageSize = PAGE_SIZE, + addedNewItems = addedNewItems, + backendTotal = data.total, + accumulatedCount = newSessions.size, + ) it.copy( isLoadingMore = false, sessions = newSessions, - total = if (hasMore) data.total else newSessions.size, - hasMore = hasMore, + total = paging.total, + hasMore = paging.hasMore, ) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt new file mode 100644 index 00000000..89c8ed16 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSearchFormattingTest.kt @@ -0,0 +1,72 @@ +package com.m57.hermescontrol.ui.sessions + +import com.m57.hermescontrol.data.model.SessionSearchResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionSearchFormattingTest { + @Test + fun `clean search snippet strips highlight markers from plain text`() { + assertEquals( + "Find the deployment logs", + cleanSearchSnippet(">>>Find<<< the deployment logs"), + ) + } + + @Test + fun `clean search snippet extracts text from JSON payload`() { + assertEquals( + "Find the deployment logs", + cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"), + ) + } + + @Test + fun `clean search snippet extracts nested text array`() { + assertEquals( + "First part second part", + cleanSearchSnippet("{\"parts\":[\">>>First<<< part\",\"second part\"]}"), + ) + } + + @Test + fun `clean search snippet normalizes whitespace`() { + assertEquals( + "hello world", + cleanSearchSnippet(" hello world \n "), + ) + } + + @Test + fun `toSessionInfo leaves title null for match layout`() { + val searchResult = + SessionSearchResult( + session_id = "test-123", + session_started = 1700000000.0, + snippet = "matched query excerpt", + source = "cli", + model = "test-model", + ) + val info = searchResult.toSessionInfo() + assertEquals("test-123", info.id) + assertNull(info.title) + assertEquals("matched query excerpt", info.preview) + assertEquals("cli", info.source) + assertEquals("test-model", info.model) + } + + @Test + fun `formatPlayedAt handles null and negative epoch`() { + assertNull(formatPlayedAt(null)) + assertNull(formatPlayedAt(0.0)) + assertNull(formatPlayedAt(-100.0)) + } + + @Test + fun `formatPlayedAt returns formatted string for valid timestamp`() { + val result = formatPlayedAt(1700000000.0) + assertNotNull(result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsPagingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsPagingTest.kt new file mode 100644 index 00000000..d8932bf3 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsPagingTest.kt @@ -0,0 +1,75 @@ +package com.m57.hermescontrol.ui.sessions + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionsPagingTest { + @Test + fun `resolveInitialPaging hasMore when received full page and total exceeds accumulated`() { + val resolution = + SessionsPaging.resolveInitialPaging( + receivedCount = 50, + pageSize = 50, + backendTotal = 120, + accumulatedCount = 50, + ) + assertTrue(resolution.hasMore) + assertEquals(120, resolution.total) + } + + @Test + fun `resolveInitialPaging no more when received less than page size`() { + val resolution = + SessionsPaging.resolveInitialPaging( + receivedCount = 30, + pageSize = 50, + backendTotal = 30, + accumulatedCount = 30, + ) + assertFalse(resolution.hasMore) + assertEquals(30, resolution.total) + } + + @Test + fun `resolveInitialPaging no more when backendTotal equals accumulated`() { + val resolution = + SessionsPaging.resolveInitialPaging( + receivedCount = 50, + pageSize = 50, + backendTotal = 50, + accumulatedCount = 50, + ) + assertFalse(resolution.hasMore) + assertEquals(50, resolution.total) + } + + @Test + fun `resolveLoadMorePaging hasMore when full page added new items and total exceeds accumulated`() { + val resolution = + SessionsPaging.resolveLoadMorePaging( + receivedCount = 50, + pageSize = 50, + addedNewItems = true, + backendTotal = 150, + accumulatedCount = 100, + ) + assertTrue(resolution.hasMore) + assertEquals(150, resolution.total) + } + + @Test + fun `resolveLoadMorePaging no more when no new items added`() { + val resolution = + SessionsPaging.resolveLoadMorePaging( + receivedCount = 50, + pageSize = 50, + addedNewItems = false, + backendTotal = 150, + accumulatedCount = 100, + ) + assertFalse(resolution.hasMore) + assertEquals(100, resolution.total) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index 9d2df0e1..1176f89c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -92,14 +92,6 @@ class SessionsViewModelTest { ) } - @Test - fun `clean search snippet extracts text from JSON payload`() { - assertEquals( - "Find the deployment logs", - cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"), - ) - } - @Test fun `sections request conversations excluding cron and automations from cron`() { val vm = createViewModel() From 9eaf9663f306101bbd8f0224429bde225a28f7e9 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 17:58:41 +0300 Subject: [PATCH 15/42] refactor(skills): extract SkillListFilter and SkillScanPresentation Separate pure skill filtering and scan presentation helpers. Wire into SkillsScreen with unit test coverage. --- .../ui/skills/SkillListFilter.kt | 39 ++++++ .../ui/skills/SkillScanPresentation.kt | 75 ++++++++++ .../hermescontrol/ui/skills/SkillsScreen.kt | 69 ++-------- .../ui/skills/SkillListFilterTest.kt | 130 ++++++++++++++++++ .../ui/skills/SkillScanPresentationTest.kt | 64 +++++++++ 5 files changed, 323 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/SkillListFilter.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/SkillScanPresentation.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/skills/SkillListFilterTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/skills/SkillScanPresentationTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillListFilter.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillListFilter.kt new file mode 100644 index 00000000..7f30c87f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillListFilter.kt @@ -0,0 +1,39 @@ +package com.m57.hermescontrol.ui.skills + +import com.m57.hermescontrol.data.model.Skill + +object SkillListFilter { + /** + * Filters skills by search query, enabled/disabled status, category, and source. + */ + fun filterSkills( + skills: List, + query: String, + selectedStatus: SkillFilter, + selectedCategory: String?, + sourceFilter: String?, + ): List = + skills.filter { skill -> + val matchesQuery = + query.isBlank() || + skill.name.contains(query, ignoreCase = true) || + skill.description?.contains(query, ignoreCase = true) == true + val matchesStatus = + when (selectedStatus) { + SkillFilter.ALL_STATUSES -> true + SkillFilter.ENABLED -> skill.enabled + SkillFilter.DISABLED -> !skill.enabled + } + val matchesCategory = + selectedCategory == null || + selectedCategory == CATEGORY_ALL || + skill.category == selectedCategory + val matchesSource = sourceFilter == null || skill.source == sourceFilter + + matchesQuery && matchesStatus && matchesCategory && matchesSource + } + + fun extractCategories(skills: List): List = skills.mapNotNull { it.category }.distinct().sorted() + + fun extractSources(skills: List): List = skills.mapNotNull { it.source }.distinct().sorted() +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillScanPresentation.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillScanPresentation.kt new file mode 100644 index 00000000..b9726261 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillScanPresentation.kt @@ -0,0 +1,75 @@ +package com.m57.hermescontrol.ui.skills + +import androidx.compose.ui.graphics.Color +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors + +enum class ScanVerdictKind { + SAFE, + DANGEROUS, + WARNING, +} + +enum class ScanPolicyKind { + ALLOW, + ASK, + BLOCK, + OTHER, +} + +object SkillScanPresentation { + fun resolveVerdictKind(verdict: String?): ScanVerdictKind = + when (verdict) { + "safe" -> ScanVerdictKind.SAFE + "dangerous" -> ScanVerdictKind.DANGEROUS + else -> ScanVerdictKind.WARNING + } + + fun resolveVerdictColor( + verdict: String?, + statusColors: HermesStatusColors, + ): Color = + when (resolveVerdictKind(verdict)) { + ScanVerdictKind.SAFE -> statusColors.success + ScanVerdictKind.DANGEROUS -> statusColors.error + ScanVerdictKind.WARNING -> statusColors.warning + } + + fun resolvePolicyKind(policy: String?): ScanPolicyKind = + when (policy) { + "allow" -> ScanPolicyKind.ALLOW + "ask" -> ScanPolicyKind.ASK + "block" -> ScanPolicyKind.BLOCK + else -> ScanPolicyKind.OTHER + } + + fun resolvePolicyLabelRes(policy: String?): Int? = + when (policy) { + "allow" -> R.string.skills_hub_scan_policy_allow + "ask" -> R.string.skills_hub_scan_policy_ask + "block" -> R.string.skills_hub_scan_policy_block + else -> null + } + + fun resolvePolicyColor( + policy: String?, + statusColors: HermesStatusColors, + ): Color = + when (policy) { + "allow" -> statusColors.success + "ask" -> statusColors.warning + "block" -> statusColors.error + else -> statusColors.neutral + } + + fun resolveSeverityColor( + severity: String?, + statusColors: HermesStatusColors, + ): Color = + when (severity) { + "critical", "high" -> statusColors.error + "medium" -> statusColors.warning + "low" -> statusColors.neutral + else -> statusColors.neutral + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index b1f06cf2..8c52332d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -256,34 +256,22 @@ private fun InstalledSkillsView( val categories = remember(state.skills) { - state.skills - .mapNotNull { it.category } - .distinct() - .sorted() + SkillListFilter.extractCategories(state.skills) } val sources = remember(state.skills) { - state.skills - .mapNotNull { it.source } - .distinct() - .sorted() + SkillListFilter.extractSources(state.skills) } val filteredSkills = remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) { - state.skills.filter { skill -> - ( - query.isBlank() || skill.name.contains(query, ignoreCase = true) || - skill.description?.contains(query, ignoreCase = true) == true - ) && - ( - selectedStatus == SkillFilter.ALL_STATUSES || - (selectedStatus == SkillFilter.ENABLED && skill.enabled) || - (selectedStatus == SkillFilter.DISABLED && !skill.enabled) - ) && - (selectedCategory == CATEGORY_ALL || skill.category == selectedCategory) && - (sourceFilter == null || skill.source == sourceFilter) - } + SkillListFilter.filterSkills( + skills = state.skills, + query = query, + selectedStatus = selectedStatus, + selectedCategory = selectedCategory, + sourceFilter = sourceFilter, + ) } Column(modifier = Modifier.fillMaxSize()) { @@ -891,26 +879,11 @@ private fun SkillScanSection( @Composable private fun ScanResultCard(result: SkillScanResponse) { val statusColors = LocalHermesStatusColors.current - val verdictColor = - when (result.verdict) { - "safe" -> statusColors.success - "dangerous" -> statusColors.error - else -> statusColors.warning - } + val verdictColor = SkillScanPresentation.resolveVerdictColor(result.verdict, statusColors) val policyLabel = - when (result.policy) { - "allow" -> stringResource(R.string.skills_hub_scan_policy_allow) - "ask" -> stringResource(R.string.skills_hub_scan_policy_ask) - "block" -> stringResource(R.string.skills_hub_scan_policy_block) - else -> result.policy.orEmpty() - } - val policyColor = - when (result.policy) { - "allow" -> statusColors.success - "ask" -> statusColors.warning - "block" -> statusColors.error - else -> statusColors.neutral - } + SkillScanPresentation.resolvePolicyLabelRes(result.policy)?.let { stringResource(it) } + ?: result.policy.orEmpty() + val policyColor = SkillScanPresentation.resolvePolicyColor(result.policy, statusColors) Card( modifier = Modifier.fillMaxWidth(), @@ -979,13 +952,6 @@ private fun ScanResultCard(result: SkillScanResponse) { } // ── Severity tally ── - val severityColors = - mapOf( - "critical" to statusColors.error, - "high" to statusColors.error, - "medium" to statusColors.warning, - "low" to statusColors.neutral, - ) Row( modifier = Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp), @@ -993,7 +959,7 @@ private fun ScanResultCard(result: SkillScanResponse) { listOf("critical", "high", "medium", "low").forEach { sev -> val count = result.severityCounts[sev] ?: 0 if (count > 0) { - val sevColor = severityColors[sev] ?: statusColors.neutral + val sevColor = SkillScanPresentation.resolveSeverityColor(sev, statusColors) Surface( color = sevColor.copy(alpha = 0.15f), shape = RoundedCornerShape(50), @@ -1039,12 +1005,7 @@ private fun ScanResultCard(result: SkillScanResponse) { @Composable private fun ScanFindingRow(finding: SkillScanFinding) { val statusColors = LocalHermesStatusColors.current - val severityColor = - when (finding.severity) { - "critical", "high" -> statusColors.error - "medium" -> statusColors.warning - else -> statusColors.neutral - } + val severityColor = SkillScanPresentation.resolveSeverityColor(finding.severity, statusColors) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillListFilterTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillListFilterTest.kt new file mode 100644 index 00000000..54ef1626 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillListFilterTest.kt @@ -0,0 +1,130 @@ +package com.m57.hermescontrol.ui.skills + +import com.m57.hermescontrol.data.model.Skill +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SkillListFilterTest { + private val sampleSkills = + listOf( + Skill( + name = "code-review", + category = "dev", + description = "Automated code review assistant", + enabled = true, + source = "built-in", + ), + Skill( + name = "deploy-bot", + category = "ops", + description = "Deploy to production environments", + enabled = false, + source = "hub", + ), + Skill( + name = "image-gen", + category = "creative", + description = "Generate creative assets", + enabled = true, + source = "hub", + ), + ) + + @Test + fun `filterSkills empty query and all statuses returns all`() { + val result = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "", + selectedStatus = SkillFilter.ALL_STATUSES, + selectedCategory = CATEGORY_ALL, + sourceFilter = null, + ) + assertEquals(3, result.size) + } + + @Test + fun `filterSkills filters by search query on name and description`() { + val nameResult = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "code", + selectedStatus = SkillFilter.ALL_STATUSES, + selectedCategory = CATEGORY_ALL, + sourceFilter = null, + ) + assertEquals(1, nameResult.size) + assertEquals("code-review", nameResult[0].name) + + val descResult = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "production", + selectedStatus = SkillFilter.ALL_STATUSES, + selectedCategory = CATEGORY_ALL, + sourceFilter = null, + ) + assertEquals(1, descResult.size) + assertEquals("deploy-bot", descResult[0].name) + } + + @Test + fun `filterSkills filters by enabled and disabled status`() { + val enabledResult = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "", + selectedStatus = SkillFilter.ENABLED, + selectedCategory = CATEGORY_ALL, + sourceFilter = null, + ) + assertEquals(2, enabledResult.size) + assertTrue(enabledResult.all { it.enabled }) + + val disabledResult = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "", + selectedStatus = SkillFilter.DISABLED, + selectedCategory = CATEGORY_ALL, + sourceFilter = null, + ) + assertEquals(1, disabledResult.size) + assertEquals("deploy-bot", disabledResult[0].name) + } + + @Test + fun `filterSkills filters by category and source`() { + val catResult = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "", + selectedStatus = SkillFilter.ALL_STATUSES, + selectedCategory = "ops", + sourceFilter = null, + ) + assertEquals(1, catResult.size) + assertEquals("deploy-bot", catResult[0].name) + + val sourceResult = + SkillListFilter.filterSkills( + skills = sampleSkills, + query = "", + selectedStatus = SkillFilter.ALL_STATUSES, + selectedCategory = CATEGORY_ALL, + sourceFilter = "built-in", + ) + assertEquals(1, sourceResult.size) + assertEquals("code-review", sourceResult[0].name) + } + + @Test + fun `extractCategories and extractSources return distinct sorted values`() { + val categories = SkillListFilter.extractCategories(sampleSkills) + assertEquals(listOf("creative", "dev", "ops"), categories) + + val sources = SkillListFilter.extractSources(sampleSkills) + assertEquals(listOf("built-in", "hub"), sources) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillScanPresentationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillScanPresentationTest.kt new file mode 100644 index 00000000..a0ee5e2a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillScanPresentationTest.kt @@ -0,0 +1,64 @@ +package com.m57.hermescontrol.ui.skills + +import androidx.compose.ui.graphics.Color +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SkillScanPresentationTest { + private val dummyColors = + HermesStatusColors( + success = Color(0xFF00FF00), + successContainer = Color(0xFF003300), + onSuccess = Color.White, + warning = Color(0xFFFFFF00), + warningContainer = Color(0xFF333300), + onWarning = Color.Black, + error = Color(0xFFFF0000), + errorContainer = Color(0xFF330000), + onError = Color.White, + info = Color(0xFF0000FF), + infoContainer = Color(0xFF000033), + onInfo = Color.White, + neutral = Color.Gray, + ) + + @Test + fun `resolveVerdictKind maps safe dangerous and fallback`() { + assertEquals(ScanVerdictKind.SAFE, SkillScanPresentation.resolveVerdictKind("safe")) + assertEquals(ScanVerdictKind.DANGEROUS, SkillScanPresentation.resolveVerdictKind("dangerous")) + assertEquals(ScanVerdictKind.WARNING, SkillScanPresentation.resolveVerdictKind("unknown")) + assertEquals(ScanVerdictKind.WARNING, SkillScanPresentation.resolveVerdictKind(null)) + } + + @Test + fun `resolveVerdictColor returns corresponding semantic color`() { + assertEquals(dummyColors.success, SkillScanPresentation.resolveVerdictColor("safe", dummyColors)) + assertEquals(dummyColors.error, SkillScanPresentation.resolveVerdictColor("dangerous", dummyColors)) + assertEquals(dummyColors.warning, SkillScanPresentation.resolveVerdictColor("suspicious", dummyColors)) + } + + @Test + fun `resolvePolicyKind and labels map allow ask block and fallback`() { + assertEquals(ScanPolicyKind.ALLOW, SkillScanPresentation.resolvePolicyKind("allow")) + assertEquals(ScanPolicyKind.ASK, SkillScanPresentation.resolvePolicyKind("ask")) + assertEquals(ScanPolicyKind.BLOCK, SkillScanPresentation.resolvePolicyKind("block")) + assertEquals(ScanPolicyKind.OTHER, SkillScanPresentation.resolvePolicyKind("custom")) + + assertEquals(R.string.skills_hub_scan_policy_allow, SkillScanPresentation.resolvePolicyLabelRes("allow")) + assertEquals(R.string.skills_hub_scan_policy_ask, SkillScanPresentation.resolvePolicyLabelRes("ask")) + assertEquals(R.string.skills_hub_scan_policy_block, SkillScanPresentation.resolvePolicyLabelRes("block")) + assertNull(SkillScanPresentation.resolvePolicyLabelRes("other")) + } + + @Test + fun `resolveSeverityColor maps critical high medium low and fallback`() { + assertEquals(dummyColors.error, SkillScanPresentation.resolveSeverityColor("critical", dummyColors)) + assertEquals(dummyColors.error, SkillScanPresentation.resolveSeverityColor("high", dummyColors)) + assertEquals(dummyColors.warning, SkillScanPresentation.resolveSeverityColor("medium", dummyColors)) + assertEquals(dummyColors.neutral, SkillScanPresentation.resolveSeverityColor("low", dummyColors)) + assertEquals(dummyColors.neutral, SkillScanPresentation.resolveSeverityColor("info", dummyColors)) + } +} From 4a93772b93017b1e0cb2ae985b32ac50af7a3be2 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 18:00:47 +0300 Subject: [PATCH 16/42] refactor(mcp): extract AddServerRequestBuilder and McpImportSummary Extract request building and import summary formatting helpers. Wire into McpServersViewModel with unit test coverage. --- .../ui/mcp/AddServerRequestBuilder.kt | 56 +++++++++++ .../hermescontrol/ui/mcp/McpImportSummary.kt | 18 ++++ .../ui/mcp/McpServersViewModel.kt | 41 +------- .../ui/mcp/AddServerRequestBuilderTest.kt | 95 +++++++++++++++++++ .../ui/mcp/McpImportSummaryTest.kt | 24 +++++ 5 files changed, 195 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilder.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/McpImportSummary.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilderTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/mcp/McpImportSummaryTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilder.kt new file mode 100644 index 00000000..4ab3070f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilder.kt @@ -0,0 +1,56 @@ +package com.m57.hermescontrol.ui.mcp + +import com.m57.hermescontrol.data.model.AddMcpServerRequest + +object AddServerRequestBuilder { + /** + * Constructs a validated AddMcpServerRequest from the add-server form parameters. + */ + fun build( + name: String, + mode: AddServerMode, + url: String, + command: String, + args: String, + auth: String, + bearerToken: String, + ): AddMcpServerRequest { + val isHttp = mode == AddServerMode.HTTP + val isStdio = mode == AddServerMode.Stdio + + val resolvedUrl = if (isHttp) url.trim().ifBlank { null } else null + val resolvedCommand = if (isStdio) command.trim().ifBlank { null } else null + val resolvedArgs = + if (isStdio && args.isNotBlank()) { + args.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } + } else { + null + } + val resolvedAuth = if (isHttp && auth != "none") auth else null + val resolvedBearerToken = + if (isHttp && auth == "header") bearerToken.trim().ifBlank { null } else null + + return AddMcpServerRequest( + name = name.trim(), + url = resolvedUrl, + command = resolvedCommand, + args = resolvedArgs, + auth = resolvedAuth, + bearerToken = resolvedBearerToken, + ) + } + + /** + * Overload reading form parameters directly from McpServersUiState. + */ + fun build(state: McpServersUiState): AddMcpServerRequest = + build( + name = state.addServerName, + mode = state.addMode, + url = state.addServerUrl, + command = state.addServerCommand, + args = state.addServerArgs, + auth = state.addServerAuth, + bearerToken = state.addServerBearerToken, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpImportSummary.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpImportSummary.kt new file mode 100644 index 00000000..8155137e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpImportSummary.kt @@ -0,0 +1,18 @@ +package com.m57.hermescontrol.ui.mcp + +object McpImportSummary { + /** + * Formats the human-readable summary message reporting the result of a batch MCP JSON import. + */ + fun formatSummary( + successCount: Int, + errors: List, + ): String { + val errSummary = errors.joinToString("; ") + return when { + errors.isEmpty() -> "Successfully imported $successCount server(s)" + successCount > 0 -> "Imported $successCount server(s), ${errors.size} failed: $errSummary" + else -> "Failed to import: $errSummary" + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 4d1d119e..4e9ead31 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -297,13 +297,7 @@ class McpServersViewModel : } _uiState.update { state -> - val errSummary = errors.joinToString("; ") - val msg = - when { - errors.isEmpty() -> "Successfully imported $successCount server(s)" - successCount > 0 -> "Imported $successCount server(s), ${errors.size} failed: $errSummary" - else -> "Failed to import: $errSummary" - } + val msg = McpImportSummary.formatSummary(successCount, errors) state.copy( isImportingJson = false, showImportDialog = false, @@ -357,38 +351,7 @@ class McpServersViewModel : } _uiState.update { it.copy(addingServer = true) } viewModelScope.launch { - val request = - AddMcpServerRequest( - name = state.addServerName.trim(), - url = if (state.addMode == AddServerMode.HTTP) state.addServerUrl.trim().ifBlank { null } else null, - command = - if (state.addMode == AddServerMode.Stdio) { - state.addServerCommand.trim().ifBlank { null } - } else { - null - }, - args = - if (state.addMode == AddServerMode.Stdio && state.addServerArgs.isNotBlank()) { - state.addServerArgs - .trim() - .split("\\s+".toRegex()) - .filter { it.isNotEmpty() } - } else { - null - }, - auth = - if (state.addMode == AddServerMode.HTTP && state.addServerAuth != "none") { - state.addServerAuth - } else { - null - }, - bearerToken = - if (state.addMode == AddServerMode.HTTP && state.addServerAuth == "header") { - state.addServerBearerToken.trim().ifBlank { null } - } else { - null - }, - ) + val request = AddServerRequestBuilder.build(state) val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.addMcpServer(request) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilderTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilderTest.kt new file mode 100644 index 00000000..36094914 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/mcp/AddServerRequestBuilderTest.kt @@ -0,0 +1,95 @@ +package com.m57.hermescontrol.ui.mcp + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AddServerRequestBuilderTest { + @Test + fun `build HTTP mode creates request with url and auth`() { + val request = + AddServerRequestBuilder.build( + name = " weather-server ", + mode = AddServerMode.HTTP, + url = " https://api.weather.com/mcp ", + command = "ignored-cmd", + args = "ignored-arg", + auth = "header", + bearerToken = " secret-token ", + ) + assertEquals("weather-server", request.name) + assertEquals("https://api.weather.com/mcp", request.url) + assertNull(request.command) + assertNull(request.args) + assertEquals("header", request.auth) + assertEquals("secret-token", request.bearerToken) + } + + @Test + fun `build HTTP mode with none auth nulls auth and bearerToken`() { + val request = + AddServerRequestBuilder.build( + name = "public-server", + mode = AddServerMode.HTTP, + url = "https://example.com/mcp", + command = "", + args = "", + auth = "none", + bearerToken = "should-be-ignored", + ) + assertEquals("public-server", request.name) + assertEquals("https://example.com/mcp", request.url) + assertNull(request.auth) + assertNull(request.bearerToken) + } + + @Test + fun `build Stdio mode creates request with command and parsed args`() { + val request = + AddServerRequestBuilder.build( + name = "local-tools", + mode = AddServerMode.Stdio, + url = "https://ignored.com", + command = " python3 ", + args = " -m mcp_server --debug ", + auth = "header", + bearerToken = "token", + ) + assertEquals("local-tools", request.name) + assertNull(request.url) + assertEquals("python3", request.command) + assertEquals(listOf("-m", "mcp_server", "--debug"), request.args) + assertNull(request.auth) + assertNull(request.bearerToken) + } + + @Test + fun `build Stdio mode with empty args nulls args`() { + val request = + AddServerRequestBuilder.build( + name = "npx-server", + mode = AddServerMode.Stdio, + url = "", + command = "npx server", + args = " ", + auth = "none", + bearerToken = "", + ) + assertEquals("npx server", request.command) + assertNull(request.args) + } + + @Test + fun `build from McpServersUiState populates fields correctly`() { + val state = + McpServersUiState( + addServerName = "state-server", + addMode = AddServerMode.HTTP, + addServerUrl = "https://state.com", + addServerAuth = "none", + ) + val request = AddServerRequestBuilder.build(state) + assertEquals("state-server", request.name) + assertEquals("https://state.com", request.url) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpImportSummaryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpImportSummaryTest.kt new file mode 100644 index 00000000..d0ba2738 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpImportSummaryTest.kt @@ -0,0 +1,24 @@ +package com.m57.hermescontrol.ui.mcp + +import org.junit.Assert.assertEquals +import org.junit.Test + +class McpImportSummaryTest { + @Test + fun `formatSummary returns success message when zero errors`() { + val result = McpImportSummary.formatSummary(3, emptyList()) + assertEquals("Successfully imported 3 server(s)", result) + } + + @Test + fun `formatSummary returns partial success message when some failed`() { + val result = McpImportSummary.formatSummary(2, listOf("serverA: timeout", "serverB: 404")) + assertEquals("Imported 2 server(s), 2 failed: serverA: timeout; serverB: 404", result) + } + + @Test + fun `formatSummary returns failure message when all failed`() { + val result = McpImportSummary.formatSummary(0, listOf("serverA: connection refused")) + assertEquals("Failed to import: serverA: connection refused", result) + } +} From a83f4725a2d3415fa736b32c5ea4118080a39b3d Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 18:12:58 +0300 Subject: [PATCH 17/42] refactor(chat): extract ChatTranscriptMerge helpers from ChatViewModel Extract tool result canonicalization, logical message equality, attachment ref stripping, and transcript merge logic from ChatViewModel. --- .../ui/chat/ChatTranscriptMerge.kt | 218 ++++++++++++++++++ .../hermescontrol/ui/chat/ChatViewModel.kt | 211 ----------------- 2 files changed, 218 insertions(+), 211 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt new file mode 100644 index 00000000..a485a53e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt @@ -0,0 +1,218 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Canonical comparison key for a tool message's result payload. + * + * WS tool messages store the full tool.complete payload + * (`{"tool_id":..., "name":..., "args":..., "result": {...}}`) while REST + * transcript rows store just the result object (`{"output":..., "exit_code":...}`). + * This key normalizes both sides — preferring the `result` field when present, + * and treating int/float JSON numbers as equal — so the two representations of + * the SAME tool call can be matched regardless of position or pagination. + * Returns null for unparseable content (no match possible). + */ +internal fun canonicalToolResultKey(content: String): String? { + val element = + try { + OkHttpProvider.json.parseToJsonElement(content) + } catch (_: Exception) { + return null + } + + fun canon(e: JsonElement): String = + when (e) { + is JsonObject -> { + e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" } + } + + is JsonArray -> { + e.joinToString(",") { canon(it) } + } + + is JsonPrimitive -> { + // Canonicalize ALL numbers through double, collapsing int/float + // spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5"). + val s = e.content + val d = s.toDoubleOrNull() + if (d != null) { + if (d == d.toLong().toDouble()) "i${d.toLong()}" else "d$d" + } else { + "s$s" + } + } + } + return when (element) { + is JsonObject -> { + element["result"]?.let { canon(it) } ?: canon(element) + } + + else -> { + canon(element) + } + } +} + +/** + * True when [a] and [b] are the same logical message (the WS-persisted and + * REST-persisted copies of one row — they carry different ids, see #771). + * Tool messages match on their normalized result payload; other roles on + * exact content, IGNORING leading/trailing whitespace. + * + * Issue #842: the app seals the RAW streamed text (which can carry leading + * blank lines the model emits before its narration), while the backend + * persists a CLEANED copy (leading whitespace stripped). An exact-content + * compare made the reload merge treat them as different messages and add + * the REST copy on top — the commentary duplicated ~10s after the stream + * ended. Trim closes the drift: the sealed live bubble is covered by the + * REST row and the duplicate never renders. + */ +internal fun sameLogicalMessage( + a: ChatMessage, + b: ChatMessage, +): Boolean { + if (a.role != b.role) return false + if (a.role == MessageRole.TOOL) { + // Issue #842: prefer the gateway's tool call id when both sides carry + // it — the REST transcript stores `tool_call_id` and the live WS + // bubble keeps it from `tool.start`. Content canonicalization cannot + // cover MCP/web tools: the REST side stores the payload as raw + // `` text (not JSON), so it has no key at all. + if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) { + return a.toolCallId == b.toolCallId + } + val ka = canonicalToolResultKey(a.content) + val kb = canonicalToolResultKey(b.content) + return ka != null && ka == kb + } + val ta = a.content.trim() + val tb = b.content.trim() + if (ta == tb) return true + // Attachment dedupe: when the user sends an image/file, the backend + // persists an ENRICHED copy of the prompt — the real caption plus + // `@image:`/`@file:` reference lines and a `[screenshot]` marker + // (tui_gateway _build_image_ref_message / run_agent flattening). The + // optimistic bubble carries the raw caption only, so the exact compare + // above fails and the REST sync renders the same message twice. Compare + // USER rows on the caption with injection lines stripped (both sides — + // stripping a clean string is a no-op). USER-only so assistant text that + // legitimately mentions these tokens is never collapsed. + if (a.role == MessageRole.USER) { + val ca = stripAttachmentRefLines(ta) + val cb = stripAttachmentRefLines(tb) + if (ca == cb) return true + } + // Issue #842: a seal race can leave the live orphan a few trailing tokens + // short of the streamed narration (the last delta was still in the + // throttled buffer when tool.start sealed the message). The backend + // persists the COMPLETE copy, so the orphan is a strict prefix of the + // REST row. Accept prefix-covering only for substantial texts (>=40 + // chars) so a short reply can never be swallowed by a longer message + // that merely starts with it. + return ta.length >= 40 && + tb.length >= 40 && + (tb.startsWith(ta) || ta.startsWith(tb)) +} + +/** + * Remove backend attachment-injection lines from a user message so the + * optimistic bubble and the server-enriched REST copy compare equal. + * Lines the gateway adds on top of the user's caption: `@image:`, + * `@file:`, and `[screenshot]` (multipart image placeholder written by + * run_agent). Blank lines left behind are dropped too. + */ +internal fun stripAttachmentRefLines(content: String): String = + content + .lines() + .map { it.trim() } + .filterNot { line -> + line.startsWith("@image:") || + line.startsWith("@file:") || + line == "[screenshot]" + }.joinToString("\n") + .trim() + +/** + * Room accumulates BOTH the WS-persisted copy (UUID id, rich tool payload, + * tool name) and the REST-persisted copy (`rest-` id, result-only payload, + * no tool name) of every message. Painting the cache verbatim renders the + * same call twice. Drop the `rest-` copy whenever a WS copy of the same + * logical message exists (issue #771). + */ +internal fun dedupeCachedMessages(messages: List): List { + val rest = messages.filter { it.id.startsWith("rest-") } + if (rest.isEmpty()) return messages + val nonRest = messages.filterNot { it.id.startsWith("rest-") } + if (nonRest.isEmpty()) return messages + val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } } + return (nonRest + keepRest).sortedBy { it.timestamp } +} + +/** + * A transcript reload must NOT yank live WS bubbles the server has not + * persisted yet. The gateway stores a tool row only once the tool + * COMPLETES server-side, so a reload that lands while a tool is running + * (app background/foreground mid-turn, reconnect re-resume, pull-refresh) + * returns a page without the tool row — replacing the list outright made + * the in-flight tool bubble vanish and left tool.complete with no RUNNING + * message to update (issue #771). + * + * Merge instead of replace: append any current message the REST page does + * not already cover (checked by id AND logical content via + * [sameLogicalMessage]) and keep chronological order. + */ +internal fun mergeTranscriptWithLive( + restMessages: List, + currentMessages: List, +): List { + val dedupedRest = restMessages.dedupeById() + val dedupedCurrent = currentMessages.dedupeById() + // User rows can carry live/cache-only metadata (for example whether the + // bubble continues the active turn). Keep that richer copy when REST + // returns the same logical row. + val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList() + val mergedRest = + dedupedRest.map { rest -> + if (rest.role != MessageRole.USER) return@map rest + val matchIndex = + currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 } + ?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) } + if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest + } + val restIds = dedupedRest.map { it.id }.toSet() + val liveTail = + dedupedCurrent.filter { old -> + old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) } + } + if (liveTail.isEmpty()) return mergedRest.dedupeById() + return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp } +} + +/** Merge a REST page without matching it against already-settled transcript rows. */ +internal fun mergeIncrementalTranscriptPage( + restMessages: List, + currentMessages: List, + sessionId: String, + offset: Int, +): List { + val settledEnd = + currentMessages.indexOfLast { message -> + serverMessageIndex(message.id, sessionId)?.let { it < offset } == true + } + return ( + currentMessages.take(settledEnd + 1) + + mergeTranscriptWithLive(restMessages, currentMessages.drop(settledEnd + 1)) + ).dedupeById() +} + +internal fun serverMessageIndex( + id: String, + sessionId: String, +): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull() + +internal fun List.dedupeById(): List = associateBy { it.id }.values.toList() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 828a53a9..551faf95 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -70,217 +70,6 @@ private sealed interface PrepareAttachmentResult { data object Unreadable : PrepareAttachmentResult } -/** - * Canonical comparison key for a tool message's result payload. - * - * WS tool messages store the full tool.complete payload - * (`{"tool_id":..., "name":..., "args":..., "result": {...}}`) while REST - * transcript rows store just the result object (`{"output":..., "exit_code":...}`). - * This key normalizes both sides — preferring the `result` field when present, - * and treating int/float JSON numbers as equal — so the two representations of - * the SAME tool call can be matched regardless of position or pagination. - * Returns null for unparseable content (no match possible). - */ -internal fun canonicalToolResultKey(content: String): String? { - val element = - try { - OkHttpProvider.json.parseToJsonElement(content) - } catch (_: Exception) { - return null - } - - fun canon(e: kotlinx.serialization.json.JsonElement): String = - when (e) { - is kotlinx.serialization.json.JsonObject -> { - e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" } - } - - is kotlinx.serialization.json.JsonArray -> { - e.joinToString(",") { canon(it) } - } - - is kotlinx.serialization.json.JsonPrimitive -> { - // Canonicalize ALL numbers through double, collapsing int/float - // spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5"). - val s = e.content - val d = s.toDoubleOrNull() - if (d != null) { - if (d == d.toLong().toDouble()) "i${d.toLong()}" else "d$d" - } else { - "s$s" - } - } - } - return when (element) { - is kotlinx.serialization.json.JsonObject -> { - element["result"]?.let { canon(it) } ?: canon(element) - } - - else -> { - canon(element) - } - } -} - -/** - * True when [a] and [b] are the same logical message (the WS-persisted and - * REST-persisted copies of one row — they carry different ids, see #771). - * Tool messages match on their normalized result payload; other roles on - * exact content, IGNORING leading/trailing whitespace. - * - * Issue #842: the app seals the RAW streamed text (which can carry leading - * blank lines the model emits before its narration), while the backend - * persists a CLEANED copy (leading whitespace stripped). An exact-content - * compare made the reload merge treat them as different messages and add - * the REST copy on top — the commentary duplicated ~10s after the stream - * ended. Trim closes the drift: the sealed live bubble is covered by the - * REST row and the duplicate never renders. - */ -internal fun sameLogicalMessage( - a: ChatMessage, - b: ChatMessage, -): Boolean { - if (a.role != b.role) return false - if (a.role == MessageRole.TOOL) { - // Issue #842: prefer the gateway's tool call id when both sides carry - // it — the REST transcript stores `tool_call_id` and the live WS - // bubble keeps it from `tool.start`. Content canonicalization cannot - // cover MCP/web tools: the REST side stores the payload as raw - // `` text (not JSON), so it has no key at all. - if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) { - return a.toolCallId == b.toolCallId - } - val ka = canonicalToolResultKey(a.content) - val kb = canonicalToolResultKey(b.content) - return ka != null && ka == kb - } - val ta = a.content.trim() - val tb = b.content.trim() - if (ta == tb) return true - // Attachment dedupe: when the user sends an image/file, the backend - // persists an ENRICHED copy of the prompt — the real caption plus - // `@image:`/`@file:` reference lines and a `[screenshot]` marker - // (tui_gateway _build_image_ref_message / run_agent flattening). The - // optimistic bubble carries the raw caption only, so the exact compare - // above fails and the REST sync renders the same message twice. Compare - // USER rows on the caption with injection lines stripped (both sides — - // stripping a clean string is a no-op). USER-only so assistant text that - // legitimately mentions these tokens is never collapsed. - if (a.role == MessageRole.USER) { - val ca = stripAttachmentRefLines(ta) - val cb = stripAttachmentRefLines(tb) - if (ca == cb) return true - } - // Issue #842: a seal race can leave the live orphan a few trailing tokens - // short of the streamed narration (the last delta was still in the - // throttled buffer when tool.start sealed the message). The backend - // persists the COMPLETE copy, so the orphan is a strict prefix of the - // REST row. Accept prefix-covering only for substantial texts (>=40 - // chars) so a short reply can never be swallowed by a longer message - // that merely starts with it. - return ta.length >= 40 && - tb.length >= 40 && - (tb.startsWith(ta) || ta.startsWith(tb)) -} - -/** - * Remove backend attachment-injection lines from a user message so the - * optimistic bubble and the server-enriched REST copy compare equal. - * Lines the gateway adds on top of the user's caption: `@image:`, - * `@file:`, and `[screenshot]` (multipart image placeholder written by - * run_agent). Blank lines left behind are dropped too. - */ -internal fun stripAttachmentRefLines(content: String): String = - content - .lines() - .map { it.trim() } - .filterNot { line -> - line.startsWith("@image:") || - line.startsWith("@file:") || - line == "[screenshot]" - }.joinToString("\n") - .trim() - -/** - * Room accumulates BOTH the WS-persisted copy (UUID id, rich tool payload, - * tool name) and the REST-persisted copy (`rest-` id, result-only payload, - * no tool name) of every message. Painting the cache verbatim renders the - * same call twice. Drop the `rest-` copy whenever a WS copy of the same - * logical message exists (issue #771). - */ -internal fun dedupeCachedMessages(messages: List): List { - val rest = messages.filter { it.id.startsWith("rest-") } - if (rest.isEmpty()) return messages - val nonRest = messages.filterNot { it.id.startsWith("rest-") } - if (nonRest.isEmpty()) return messages - val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } } - return (nonRest + keepRest).sortedBy { it.timestamp } -} - -/** - * A transcript reload must NOT yank live WS bubbles the server has not - * persisted yet. The gateway stores a tool row only once the tool - * COMPLETES server-side, so a reload that lands while a tool is running - * (app background/foreground mid-turn, reconnect re-resume, pull-refresh) - * returns a page without the tool row — replacing the list outright made - * the in-flight tool bubble vanish and left tool.complete with no RUNNING - * message to update (issue #771). - * - * Merge instead of replace: append any current message the REST page does - * not already cover (checked by id AND logical content via - * [sameLogicalMessage]) and keep chronological order. - */ -internal fun mergeTranscriptWithLive( - restMessages: List, - currentMessages: List, -): List { - val dedupedRest = restMessages.dedupeById() - val dedupedCurrent = currentMessages.dedupeById() - // User rows can carry live/cache-only metadata (for example whether the - // bubble continues the active turn). Keep that richer copy when REST - // returns the same logical row. - val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList() - val mergedRest = - dedupedRest.map { rest -> - if (rest.role != MessageRole.USER) return@map rest - val matchIndex = - currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 } - ?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) } - if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest - } - val restIds = dedupedRest.map { it.id }.toSet() - val liveTail = - dedupedCurrent.filter { old -> - old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) } - } - if (liveTail.isEmpty()) return mergedRest.dedupeById() - return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp } -} - -/** Merge a REST page without matching it against already-settled transcript rows. */ -internal fun mergeIncrementalTranscriptPage( - restMessages: List, - currentMessages: List, - sessionId: String, - offset: Int, -): List { - val settledEnd = - currentMessages.indexOfLast { message -> - serverMessageIndex(message.id, sessionId)?.let { it < offset } == true - } - return ( - currentMessages.take(settledEnd + 1) + - mergeTranscriptWithLive(restMessages, currentMessages.drop(settledEnd + 1)) - ).dedupeById() -} - -private fun serverMessageIndex( - id: String, - sessionId: String, -): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull() - -internal fun List.dedupeById(): List = associateBy { it.id }.values.toList() - data class ChatUiState( val messages: List = emptyList(), val currentSessionId: String? = null, From 70f331b9311b8cc4fedcb8d0fa608fa412c5c05e Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 18:19:47 +0300 Subject: [PATCH 18/42] refactor(markdown): extract MarkdownModels and MarkdownParser Split AST block models and zero-Compose Markdown block parser into ui/chat/markdown/ package. Wire into MarkdownText. --- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 650 +----------------- .../ui/chat/markdown/MarkdownModels.kt | 99 +++ .../ui/chat/markdown/MarkdownParser.kt | 539 +++++++++++++++ .../ui/chat/MarkdownTextBenchmark.kt | 1 + .../ui/chat/MarkdownTextFeatureTest.kt | 4 + 5 files changed, 649 insertions(+), 644 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index 71d8c628..ffb7b3a4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -61,15 +61,16 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.ui.chat.markdown.FnNote +import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment +import com.m57.hermescontrol.ui.chat.markdown.MdBlock +import com.m57.hermescontrol.ui.chat.markdown.TableAlign +import com.m57.hermescontrol.ui.chat.markdown.parseBlocks +import com.m57.hermescontrol.ui.chat.markdown.splitInlineMath import com.m57.hermescontrol.util.BidiUtils private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""") private val TABLE_COL_WIDTH = 160.dp -private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""") -private val TASK_LINE_RE = Regex("""^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$""") -private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""") -private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""") -private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""") private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr @@ -508,32 +509,6 @@ fun MarkdownText( } } -/** - * Matches a Markdown image: `![alt](uri)`. The `uri` may be an http(s) URL - * (gateway-served media the phone can reach), a `data:image/...;base64,...` - * data URL (e.g. agent-delivered inline media), or any other resolvable model - * string (Coil handles all three). Bare `![]()` with empty uri is skipped. - */ -private val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""") - -private fun tryParseImage(line: String): MdBlock.Image? { - val m = IMAGE_RE.matchAt(line, 0) ?: return null - val uri = m.groupValues[2].trim() - if (uri.isEmpty()) return null - return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim()) -} - -private fun tryParseVideo(line: String): MdBlock.Video? { - val m = IMAGE_RE.matchAt(line, 0) ?: return null - val uri = m.groupValues[2].trim() - if (uri.isEmpty()) return null - val ext = uri.substringAfterLast('.', "").lowercase() - if (ext in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || uri.contains("video/", ignoreCase = true)) { - return MdBlock.Video(uri = uri, alt = m.groupValues[1].trim()) - } - return null -} - @Composable private fun MarkdownInlineText( text: String, @@ -702,531 +677,6 @@ private fun tableTextAlign(align: TableAlign?): TextAlign? = else -> null } -internal sealed interface InlineMathSegment { - data class Text( - val value: String, - ) : InlineMathSegment - - data class Math( - val latex: String, - ) : InlineMathSegment -} - -/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */ -internal fun splitInlineMath(text: String): List { - val segments = mutableListOf() - var plainStart = 0 - var i = 0 - - while (i < text.length) { - if (text[i] == '`' && !text.isEscaped(i)) { - val codeEnd = text.indexOf('`', i + 1) - i = if (codeEnd == -1) text.length else codeEnd + 1 - continue - } - if (text.startsWith("\\(", i)) { - val end = text.indexOf("\\)", i + 2) - if (end != -1 && end > i + 2) { - if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i))) - segments.add(InlineMathSegment.Math(text.substring(i + 2, end))) - i = end + 2 - plainStart = i - continue - } - } - if (text[i] != '$' || text.isEscaped(i) || text.getOrNull(i + 1) == '$') { - i++ - continue - } - - var end = i + 1 - while (end < text.length && (text[end] != '$' || text.isEscaped(end))) end++ - if (end >= text.length || end == i + 1 || text[end - 1].isWhitespace()) { - i++ - continue - } - - if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i))) - segments.add(InlineMathSegment.Math(text.substring(i + 1, end))) - i = end + 1 - plainStart = i - } - - if (plainStart < text.length) segments.add(InlineMathSegment.Text(text.substring(plainStart))) - return segments.ifEmpty { listOf(InlineMathSegment.Text(text)) } -} - -private fun String.isEscaped(index: Int): Boolean { - var slashes = 0 - var i = index - 1 - while (i >= 0 && this[i] == '\\') { - slashes++ - i-- - } - return slashes % 2 == 1 -} - -private fun tryParseDisplayMath( - lines: List, - start: Int, -): Pair? { - val first = lines[start].trim() - if (first.startsWith("\\[")) { - if (first.length > 4 && first.endsWith("\\]")) { - val latex = first.substring(2, first.length - 2).trim() - return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 } - } - if (first != "\\[") return null - val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "\\]" } ?: return null - val latex = lines.subList(start + 1, end).joinToString("\n").trim() - return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 } - } - if (!first.startsWith("$$")) return null - if (first.length > 4 && first.endsWith("$$")) { - val latex = first.substring(2, first.length - 2).trim() - return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 } - } - if (first != "$$") return null - - val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "$$" } ?: return null - val latex = lines.subList(start + 1, end).joinToString("\n").trim() - return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 } -} - -/** - * Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first; - * everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs. - */ -internal fun parseBlocks(src: String): List { - val lines = src.lines() - val blocks = mutableListOf() - val footnotes = mutableListOf() - var i = 0 - - while (i < lines.size) { - val line = lines[i] - - // Footnote definition: [^id]: text (collected, not rendered inline) - val fnMatch = FN_DEF_RE.matchAt(line, 0) - if (fnMatch != null) { - footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2])) - i++ - continue - } - - val displayMath = tryParseDisplayMath(lines, i) - if (displayMath != null) { - blocks.add(displayMath.first) - i = displayMath.second - continue - } - - val codeFence = parseCodeFenceStart(line) - when { - codeFence != null -> { - val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) } - if (end != null) { - blocks.add( - MdBlock.Code( - code = lines.subList(i + 1, end).joinToString("\n"), - language = codeFence.language, - ), - ) - i = end + 1 - } else { - blocks.add( - MdBlock.Code( - code = lines.subList(i + 1, lines.size).joinToString("\n"), - language = codeFence.language, - ), - ) - i = lines.size - } - } - - line.isBlank() -> { - i++ - } - - isHorizontalRule(line) -> { - blocks.add(MdBlock.Hr) - i++ - } - - // Heading: requires a space after the '#' run so "#572" stays a paragraph - isValidHeading(line) -> { - val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6) - blocks.add(MdBlock.Heading(level, line.substring(level).trim())) - i++ - } - - isTableStart(lines, i) -> { - val (header, alignments, body) = parseTable(lines, i) - blocks.add(MdBlock.Table(header, alignments, body)) - i += body.size + 2 - } - - line.startsWith(">") -> { - val quote = mutableListOf() - while (i < lines.size && lines[i].startsWith(">")) { - quote.add(lines[i].removePrefix(">").trim()) - i++ - } - blocks.add(MdBlock.Quote(quote.joinToString("\n"))) - } - - // Definition list: term line followed by one+ ": definition" lines - isDefListStart(lines, i) -> { - val term = line.trim() - val defs = mutableListOf() - i++ - while (i < lines.size && lines[i].trim().startsWith(":")) { - defs.add(lines[i].trim().removePrefix(":").trim()) - i++ - } - blocks.add(MdBlock.DefList(listOf(DefItem(term, defs)))) - } - - LIST_ITEM_LINE_RE.matches(line) -> { - val (listBlocks, nextIndex) = parseList(lines, i) - blocks.addAll(listBlocks) - i = nextIndex - } - - // Standalone Markdown image: ![alt](uri) on its own line. - // Inline images inside a paragraph are left as-is (rendered as text) - // to keep scope tight; standalone is the common agent-media case. - line.isNotBlank() && tryParseVideo(line) != null -> { - blocks.add(tryParseVideo(line)!!) - i++ - } - - line.isNotBlank() && tryParseImage(line) != null -> { - blocks.add(tryParseImage(line)!!) - i++ - } - - else -> { - i = fallthroughToParagraph(lines, i, blocks) - } - } - } - - if (footnotes.isNotEmpty()) { - blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) })) - } - return blocks -} - -private sealed interface ParsedItem { - val indent: Int - val continuationLines: MutableList - - data class Bullet( - override val indent: Int, - val text: String, - override val continuationLines: MutableList = mutableListOf(), - ) : ParsedItem - - data class Task( - override val indent: Int, - val checked: Boolean, - val text: String, - override val continuationLines: MutableList = mutableListOf(), - ) : ParsedItem - - data class Ordered( - override val indent: Int, - val rawNumber: Int, - val text: String, - override val continuationLines: MutableList = mutableListOf(), - ) : ParsedItem -} - -private fun computeIndent(prefix: String): Int { - var count = 0 - for (ch in prefix) { - if (ch == '\t') { - count += 4 - (count % 4) - } else { - count++ - } - } - return count -} - -private fun parseList( - lines: List, - startIndex: Int, -): Pair, Int> { - val items = mutableListOf() - var j = startIndex - - while (j < lines.size) { - val line = lines[j] - - val taskMatch = TASK_LINE_RE.matchEntire(line) - val bulletMatch = if (taskMatch == null) BULLET_LINE_RE.matchEntire(line) else null - val orderedMatch = if (taskMatch == null && bulletMatch == null) ORDERED_LINE_RE.matchEntire(line) else null - - if (taskMatch != null) { - val indent = computeIndent(taskMatch.groupValues[1]) - val checked = taskMatch.groupValues[2].equals("x", ignoreCase = true) - val text = taskMatch.groupValues[3] - items.add(ParsedItem.Task(indent, checked, text)) - j++ - } else if (bulletMatch != null) { - val indent = computeIndent(bulletMatch.groupValues[1]) - val text = bulletMatch.groupValues[2] - items.add(ParsedItem.Bullet(indent, text)) - j++ - } else if (orderedMatch != null) { - val indent = computeIndent(orderedMatch.groupValues[1]) - val num = orderedMatch.groupValues[2].toIntOrNull() ?: 1 - val text = orderedMatch.groupValues[3] - items.add(ParsedItem.Ordered(indent, num, text)) - j++ - } else if (line.isBlank()) { - // Check if there is a following list item - var lookahead = j + 1 - while (lookahead < lines.size && lines[lookahead].isBlank()) { - lookahead++ - } - if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) { - j = lookahead - } else { - break - } - } else { - // Check if this is a continuation line for the previous list item - val indent = computeIndent(line.takeWhile { it == ' ' || it == '\t' }) - if (items.isNotEmpty() && (indent >= 2 || line.startsWith(" "))) { - items.last().continuationLines.add(line.trim()) - j++ - } else { - break - } - } - } - - if (items.isEmpty()) { - return emptyList() to j - } - - val blocks = mutableListOf() - - // Track state of list items and their relative nesting depth using an indent stack - val indentStack = mutableListOf() // maps level (index) -> base indent - var lastItemWasOrdered = false - var currentSequenceNumber = 1 - var lastLevel = -1 - - for (item in items) { - // Resolve level using the indent stack - val level: Int - if (indentStack.isEmpty() || item.indent == 0) { - indentStack.clear() - indentStack.add(item.indent) - level = 0 - } else if (item.indent > indentStack.last()) { - indentStack.add(item.indent) - level = indentStack.size - 1 - } else if (item.indent == indentStack.last()) { - level = indentStack.size - 1 - } else { - // Unwind stack to the matching or nearest smaller indent - while (indentStack.size > 1 && indentStack.last() > item.indent) { - indentStack.removeAt(indentStack.size - 1) - } - level = indentStack.size - 1 - } - - val fullText = - if (item.continuationLines.isEmpty()) { - when (item) { - is ParsedItem.Bullet -> item.text - is ParsedItem.Task -> item.text - is ParsedItem.Ordered -> item.text - } - } else { - val base = - when (item) { - is ParsedItem.Bullet -> item.text - is ParsedItem.Task -> item.text - is ParsedItem.Ordered -> item.text - } - base + " " + item.continuationLines.joinToString(" ") - } - - when (item) { - is ParsedItem.Bullet -> { - blocks.add(MdBlock.Bullet(fullText, level = level)) - lastItemWasOrdered = false - lastLevel = level - } - - is ParsedItem.Task -> { - blocks.add(MdBlock.Task(item.checked, fullText, level = level)) - lastItemWasOrdered = false - lastLevel = level - } - - is ParsedItem.Ordered -> { - val indexToUse = - if (level == lastLevel && lastItemWasOrdered) { - if (item.rawNumber > currentSequenceNumber) { - currentSequenceNumber = item.rawNumber - } else { - currentSequenceNumber++ - } - currentSequenceNumber - } else { - currentSequenceNumber = item.rawNumber - currentSequenceNumber - } - blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level)) - lastItemWasOrdered = true - lastLevel = level - } - } - } - - return blocks to j -} - -private fun isHorizontalRule(line: String): Boolean { - val trimmed = line.trim() - if (trimmed.length < 3) return false - val c = trimmed[0] - return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c } -} - -private fun isTableStart( - lines: List, - i: Int, -): Boolean { - val l = lines[i] - if (!l.contains('|')) return false - if (i + 1 >= lines.size) return false - return isTableSeparator(lines[i + 1]) -} - -private fun isTableSeparator(line: String): Boolean { - val t = line.trim().trim('|') - if (t.isEmpty()) return false - // every cell must be only dashes/colons/spaces, with at least one dash - return t.split('|').all { cell -> - val c = cell.trim() - c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' } - } -} - -private fun parseTable( - lines: List, - i: Int, -): Triple, List, List>> { - val header = splitRow(lines[i]) - val alignments = - splitRow(lines[i + 1]).map { cell -> - val c = cell.trim() - when { - c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER - c.endsWith(":") -> TableAlign.RIGHT - c.startsWith(":") -> TableAlign.LEFT - else -> TableAlign.LEFT - } - } - val body = mutableListOf>() - var j = i + 2 - while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) { - body.add(splitRow(lines[j])) - j++ - } - return Triple(header, alignments, body) -} - -private fun splitRow(line: String): List { - val trimmed = line.trim().trim('|') - return trimmed.split('|').map { it.trim() } -} - -private data class CodeFenceInfo( - val fenceChar: Char, - val fenceLength: Int, - val language: String?, -) - -private fun parseCodeFenceStart(line: String): CodeFenceInfo? { - val trimmed = line.trimStart() - if (trimmed.length < 3) return null - val firstChar = trimmed[0] - if (firstChar != '`' && firstChar != '~') return null - val count = trimmed.takeWhile { it == firstChar }.length - if (count < 3) return null - val rest = trimmed.substring(count).trim() - if (firstChar == '`' && rest.contains('`')) return null - return CodeFenceInfo( - fenceChar = firstChar, - fenceLength = count, - language = rest.ifBlank { null }, - ) -} - -private fun isCodeFenceEnd( - line: String, - fence: CodeFenceInfo, -): Boolean { - val trimmed = line.trimStart() - val count = trimmed.takeWhile { it == fence.fenceChar }.length - if (count < fence.fenceLength) return false - val rest = trimmed.substring(count).trim() - return rest.isEmpty() -} - -private fun isDefListStart( - lines: List, - i: Int, -): Boolean { - val l = lines[i].trim() - if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || parseCodeFenceStart(l) != null) return false - if (LIST_ITEM_LINE_RE.matches(lines[i])) return false - if (i + 1 >= lines.size) return false - return lines[i + 1].trim().startsWith(":") -} - -private fun isValidHeading(line: String): Boolean { - if (!line.startsWith("#")) return false - val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6) - return level < line.length && line[level] == ' ' -} - -private fun fallthroughToParagraph( - lines: List, - start: Int, - blocks: MutableList, -): Int { - var i = start - val para = mutableListOf() - while ( - i < lines.size && - lines[i].isNotBlank() && - parseCodeFenceStart(lines[i]) == null && - !isValidHeading(lines[i]) && - !lines[i].startsWith(">") && - !LIST_ITEM_LINE_RE.matches(lines[i]) && - !isHorizontalRule(lines[i]) && - !isTableStart(lines, i) && - !isDefListStart(lines, i) && - tryParseDisplayMath(lines, i) == null && - FN_DEF_RE.matchAt(lines[i], 0) == null - ) { - para.add(lines[i]) - i++ - } - if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n"))) - return i -} - /** * Inline Markdown -> AnnotatedString. Handles `code`, **bold**, *italic*, ***bold italic***, * ~~strike~~, ==highlight==, ^sup^, ~sub~, keys, [^ref] footnotes, [text](url) and @@ -1549,94 +999,6 @@ private fun parseInlineSource( } } -internal sealed interface MdBlock { - data class Code( - val code: String, - val language: String? = null, - ) : MdBlock - - data class Math( - val latex: String, - ) : MdBlock - - data class Heading( - val level: Int, - val text: String, - ) : MdBlock - - data class Bullet( - val text: String, - val level: Int = 0, - ) : MdBlock - - data class Task( - val checked: Boolean, - val text: String, - val level: Int = 0, - ) : MdBlock - - data class Ordered( - val index: Int, - val text: String, - val level: Int = 0, - ) : MdBlock - - data class Image( - val uri: String, - val alt: String = "", - ) : MdBlock - - data class Video( - val uri: String, - val alt: String = "", - ) : MdBlock - - data class Quote( - val text: String, - ) : MdBlock - - data class Paragraph( - val text: String, - ) : MdBlock - - data class Table( - val header: List, - val alignments: List, - val rows: List>, - ) : MdBlock - - object Hr : MdBlock - - data class DefList( - val items: List, - ) : MdBlock - - data class Footnotes( - val notes: List, - ) : MdBlock -} - -internal data class DefItem( - val term: String, - val definitions: List, -) - -internal data class Footnote( - val id: String, - val text: String, -) - -internal data class FnNote( - val id: String, - val text: String, -) - -internal enum class TableAlign { - LEFT, - CENTER, - RIGHT, -} - private fun resolveImageUrl(uri: String): String { val trimmed = uri.trim() if (trimmed.isBlank()) return uri diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt new file mode 100644 index 00000000..87bfed26 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt @@ -0,0 +1,99 @@ +package com.m57.hermescontrol.ui.chat.markdown + +sealed interface MdBlock { + data class Code( + val code: String, + val language: String? = null, + ) : MdBlock + + data class Math( + val latex: String, + ) : MdBlock + + data class Heading( + val level: Int, + val text: String, + ) : MdBlock + + data class Bullet( + val text: String, + val level: Int = 0, + ) : MdBlock + + data class Task( + val checked: Boolean, + val text: String, + val level: Int = 0, + ) : MdBlock + + data class Ordered( + val index: Int, + val text: String, + val level: Int = 0, + ) : MdBlock + + data class Image( + val uri: String, + val alt: String = "", + ) : MdBlock + + data class Video( + val uri: String, + val alt: String = "", + ) : MdBlock + + data class Quote( + val text: String, + ) : MdBlock + + data class Paragraph( + val text: String, + ) : MdBlock + + data class Table( + val header: List, + val alignments: List, + val rows: List>, + ) : MdBlock + + object Hr : MdBlock + + data class DefList( + val items: List, + ) : MdBlock + + data class Footnotes( + val notes: List, + ) : MdBlock +} + +data class DefItem( + val term: String, + val definitions: List, +) + +data class Footnote( + val id: String, + val text: String, +) + +data class FnNote( + val id: String, + val text: String, +) + +enum class TableAlign { + LEFT, + CENTER, + RIGHT, +} + +sealed interface InlineMathSegment { + data class Text( + val value: String, + ) : InlineMathSegment + + data class Math( + val latex: String, + ) : InlineMathSegment +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt new file mode 100644 index 00000000..d7879285 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt @@ -0,0 +1,539 @@ +package com.m57.hermescontrol.ui.chat.markdown + +private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""") +private val TASK_LINE_RE = Regex("""^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$""") +private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""") +private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""") +private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""") +private val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""") + +/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */ +fun splitInlineMath(text: String): List { + val segments = mutableListOf() + var plainStart = 0 + var i = 0 + + while (i < text.length) { + if (text[i] == '`' && !text.isEscaped(i)) { + val codeEnd = text.indexOf('`', i + 1) + i = if (codeEnd == -1) text.length else codeEnd + 1 + continue + } + if (text.startsWith("\\(", i)) { + val end = text.indexOf("\\)", i + 2) + if (end != -1 && end > i + 2) { + if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i))) + segments.add(InlineMathSegment.Math(text.substring(i + 2, end))) + i = end + 2 + plainStart = i + continue + } + } + if (text[i] != '$' || text.isEscaped(i) || text.getOrNull(i + 1) == '$') { + i++ + continue + } + + var end = i + 1 + while (end < text.length && (text[end] != '$' || text.isEscaped(end))) end++ + if (end >= text.length || end == i + 1 || text[end - 1].isWhitespace()) { + i++ + continue + } + + if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i))) + segments.add(InlineMathSegment.Math(text.substring(i + 1, end))) + i = end + 1 + plainStart = i + } + + if (plainStart < text.length) segments.add(InlineMathSegment.Text(text.substring(plainStart))) + return segments.ifEmpty { listOf(InlineMathSegment.Text(text)) } +} + +private fun String.isEscaped(index: Int): Boolean { + var slashes = 0 + var i = index - 1 + while (i >= 0 && this[i] == '\\') { + slashes++ + i-- + } + return slashes % 2 == 1 +} + +private fun tryParseDisplayMath( + lines: List, + start: Int, +): Pair? { + val first = lines[start].trim() + if (first.startsWith("\\[")) { + if (first.length > 4 && first.endsWith("\\]")) { + val latex = first.substring(2, first.length - 2).trim() + return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 } + } + if (first != "\\[") return null + val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "\\]" } ?: return null + val latex = lines.subList(start + 1, end).joinToString("\n").trim() + return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 } + } + if (!first.startsWith("$$")) return null + if (first.length > 4 && first.endsWith("$$")) { + val latex = first.substring(2, first.length - 2).trim() + return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 } + } + if (first != "$$") return null + + val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "$$" } ?: return null + val latex = lines.subList(start + 1, end).joinToString("\n").trim() + return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 } +} + +/** + * Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first; + * everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs. + */ +fun parseBlocks(src: String): List { + val lines = src.lines() + val blocks = mutableListOf() + val footnotes = mutableListOf() + var i = 0 + + while (i < lines.size) { + val line = lines[i] + + // Footnote definition: [^id]: text (collected, not rendered inline) + val fnMatch = FN_DEF_RE.matchAt(line, 0) + if (fnMatch != null) { + footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2])) + i++ + continue + } + + val displayMath = tryParseDisplayMath(lines, i) + if (displayMath != null) { + blocks.add(displayMath.first) + i = displayMath.second + continue + } + + val codeFence = parseCodeFenceStart(line) + when { + codeFence != null -> { + val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) } + if (end != null) { + blocks.add( + MdBlock.Code( + code = lines.subList(i + 1, end).joinToString("\n"), + language = codeFence.language, + ), + ) + i = end + 1 + } else { + blocks.add( + MdBlock.Code( + code = lines.subList(i + 1, lines.size).joinToString("\n"), + language = codeFence.language, + ), + ) + i = lines.size + } + } + + line.isBlank() -> { + i++ + } + + isHorizontalRule(line) -> { + blocks.add(MdBlock.Hr) + i++ + } + + // Heading: requires a space after the '#' run so "#572" stays a paragraph + isValidHeading(line) -> { + val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6) + blocks.add(MdBlock.Heading(level, line.substring(level).trim())) + i++ + } + + isTableStart(lines, i) -> { + val (header, alignments, body) = parseTable(lines, i) + blocks.add(MdBlock.Table(header, alignments, body)) + i += body.size + 2 + } + + line.startsWith(">") -> { + val quote = mutableListOf() + while (i < lines.size && lines[i].startsWith(">")) { + quote.add(lines[i].removePrefix(">").trim()) + i++ + } + blocks.add(MdBlock.Quote(quote.joinToString("\n"))) + } + + // Definition list: term line followed by one+ ": definition" lines + isDefListStart(lines, i) -> { + val term = line.trim() + val defs = mutableListOf() + i++ + while (i < lines.size && lines[i].trim().startsWith(":")) { + defs.add(lines[i].trim().removePrefix(":").trim()) + i++ + } + blocks.add(MdBlock.DefList(listOf(DefItem(term, defs)))) + } + + LIST_ITEM_LINE_RE.matches(line) -> { + val (listBlocks, nextIndex) = parseList(lines, i) + blocks.addAll(listBlocks) + i = nextIndex + } + + // Standalone Markdown image: ![alt](uri) on its own line. + line.isNotBlank() && tryParseVideo(line) != null -> { + blocks.add(tryParseVideo(line)!!) + i++ + } + + line.isNotBlank() && tryParseImage(line) != null -> { + blocks.add(tryParseImage(line)!!) + i++ + } + + else -> { + i = fallthroughToParagraph(lines, i, blocks) + } + } + } + + if (footnotes.isNotEmpty()) { + blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) })) + } + return blocks +} + +private sealed interface ParsedItem { + val indent: Int + val continuationLines: MutableList + + data class Bullet( + override val indent: Int, + val text: String, + override val continuationLines: MutableList = mutableListOf(), + ) : ParsedItem + + data class Task( + override val indent: Int, + val checked: Boolean, + val text: String, + override val continuationLines: MutableList = mutableListOf(), + ) : ParsedItem + + data class Ordered( + override val indent: Int, + val rawNumber: Int, + val text: String, + override val continuationLines: MutableList = mutableListOf(), + ) : ParsedItem +} + +private fun computeIndent(prefix: String): Int { + var count = 0 + for (ch in prefix) { + if (ch == '\t') { + count += 4 - (count % 4) + } else { + count++ + } + } + return count +} + +private fun parseList( + lines: List, + startIndex: Int, +): Pair, Int> { + val items = mutableListOf() + var j = startIndex + + while (j < lines.size) { + val line = lines[j] + + val taskMatch = TASK_LINE_RE.matchEntire(line) + val bulletMatch = if (taskMatch == null) BULLET_LINE_RE.matchEntire(line) else null + val orderedMatch = if (taskMatch == null && bulletMatch == null) ORDERED_LINE_RE.matchEntire(line) else null + + if (taskMatch != null) { + val indent = computeIndent(taskMatch.groupValues[1]) + val checked = taskMatch.groupValues[2].equals("x", ignoreCase = true) + val text = taskMatch.groupValues[3] + items.add(ParsedItem.Task(indent, checked, text)) + j++ + } else if (bulletMatch != null) { + val indent = computeIndent(bulletMatch.groupValues[1]) + val text = bulletMatch.groupValues[2] + items.add(ParsedItem.Bullet(indent, text)) + j++ + } else if (orderedMatch != null) { + val indent = computeIndent(orderedMatch.groupValues[1]) + val num = orderedMatch.groupValues[2].toIntOrNull() ?: 1 + val text = orderedMatch.groupValues[3] + items.add(ParsedItem.Ordered(indent, num, text)) + j++ + } else if (line.isBlank()) { + // Check if there is a following list item + var lookahead = j + 1 + while (lookahead < lines.size && lines[lookahead].isBlank()) { + lookahead++ + } + if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) { + j = lookahead + } else { + break + } + } else { + // Check if this is a continuation line for the previous list item + val indent = computeIndent(line.takeWhile { it == ' ' || it == '\t' }) + if (items.isNotEmpty() && (indent >= 2 || line.startsWith(" "))) { + items.last().continuationLines.add(line.trim()) + j++ + } else { + break + } + } + } + + if (items.isEmpty()) { + return emptyList() to j + } + + val blocks = mutableListOf() + + // Track state of list items and their relative nesting depth using an indent stack + val indentStack = mutableListOf() // maps level (index) -> base indent + var lastItemWasOrdered = false + var currentSequenceNumber = 1 + var lastLevel = -1 + + for (item in items) { + // Resolve level using the indent stack + val level: Int + if (indentStack.isEmpty() || item.indent == 0) { + indentStack.clear() + indentStack.add(item.indent) + level = 0 + } else if (item.indent > indentStack.last()) { + indentStack.add(item.indent) + level = indentStack.size - 1 + } else if (item.indent == indentStack.last()) { + level = indentStack.size - 1 + } else { + // Unwind stack to the matching or nearest smaller indent + while (indentStack.size > 1 && indentStack.last() > item.indent) { + indentStack.removeAt(indentStack.size - 1) + } + level = indentStack.size - 1 + } + + val fullText = + if (item.continuationLines.isEmpty()) { + when (item) { + is ParsedItem.Bullet -> item.text + is ParsedItem.Task -> item.text + is ParsedItem.Ordered -> item.text + } + } else { + val base = + when (item) { + is ParsedItem.Bullet -> item.text + is ParsedItem.Task -> item.text + is ParsedItem.Ordered -> item.text + } + base + " " + item.continuationLines.joinToString(" ") + } + + when (item) { + is ParsedItem.Bullet -> { + blocks.add(MdBlock.Bullet(fullText, level = level)) + lastItemWasOrdered = false + lastLevel = level + } + + is ParsedItem.Task -> { + blocks.add(MdBlock.Task(item.checked, fullText, level = level)) + lastItemWasOrdered = false + lastLevel = level + } + + is ParsedItem.Ordered -> { + val indexToUse = + if (level == lastLevel && lastItemWasOrdered) { + if (item.rawNumber > currentSequenceNumber) { + currentSequenceNumber = item.rawNumber + } else { + currentSequenceNumber++ + } + currentSequenceNumber + } else { + currentSequenceNumber = item.rawNumber + currentSequenceNumber + } + blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level)) + lastItemWasOrdered = true + lastLevel = level + } + } + } + + return blocks to j +} + +private fun isHorizontalRule(line: String): Boolean { + val trimmed = line.trim() + if (trimmed.length < 3) return false + val c = trimmed[0] + return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c } +} + +private fun isTableStart( + lines: List, + i: Int, +): Boolean { + val l = lines[i] + if (!l.contains('|')) return false + if (i + 1 >= lines.size) return false + return isTableSeparator(lines[i + 1]) +} + +private fun isTableSeparator(line: String): Boolean { + val t = line.trim().trim('|') + if (t.isEmpty()) return false + // every cell must be only dashes/colons/spaces, with at least one dash + return t.split('|').all { cell -> + val c = cell.trim() + c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' } + } +} + +private fun parseTable( + lines: List, + i: Int, +): Triple, List, List>> { + val header = splitRow(lines[i]) + val alignments = + splitRow(lines[i + 1]).map { cell -> + val c = cell.trim() + when { + c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER + c.endsWith(":") -> TableAlign.RIGHT + c.startsWith(":") -> TableAlign.LEFT + else -> TableAlign.LEFT + } + } + val body = mutableListOf>() + var j = i + 2 + while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) { + body.add(splitRow(lines[j])) + j++ + } + return Triple(header, alignments, body) +} + +private fun splitRow(line: String): List { + val trimmed = line.trim().trim('|') + return trimmed.split('|').map { it.trim() } +} + +private data class CodeFenceInfo( + val fenceChar: Char, + val fenceLength: Int, + val language: String?, +) + +private fun parseCodeFenceStart(line: String): CodeFenceInfo? { + val trimmed = line.trimStart() + if (trimmed.length < 3) return null + val firstChar = trimmed[0] + if (firstChar != '`' && firstChar != '~') return null + val count = trimmed.takeWhile { it == firstChar }.length + if (count < 3) return null + val rest = trimmed.substring(count).trim() + if (firstChar == '`' && rest.contains('`')) return null + return CodeFenceInfo( + fenceChar = firstChar, + fenceLength = count, + language = rest.ifBlank { null }, + ) +} + +private fun isCodeFenceEnd( + line: String, + fence: CodeFenceInfo, +): Boolean { + val trimmed = line.trimStart() + val count = trimmed.takeWhile { it == fence.fenceChar }.length + if (count < fence.fenceLength) return false + val rest = trimmed.substring(count).trim() + return rest.isEmpty() +} + +private fun isDefListStart( + lines: List, + i: Int, +): Boolean { + val l = lines[i].trim() + if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || parseCodeFenceStart(l) != null) return false + if (LIST_ITEM_LINE_RE.matches(lines[i])) return false + if (i + 1 >= lines.size) return false + return lines[i + 1].trim().startsWith(":") +} + +private fun isValidHeading(line: String): Boolean { + if (!line.startsWith("#")) return false + val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6) + return level < line.length && line[level] == ' ' +} + +private fun tryParseImage(line: String): MdBlock.Image? { + val m = IMAGE_RE.matchAt(line, 0) ?: return null + val uri = m.groupValues[2].trim() + if (uri.isEmpty()) return null + return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim()) +} + +private fun tryParseVideo(line: String): MdBlock.Video? { + val m = IMAGE_RE.matchAt(line, 0) ?: return null + val uri = m.groupValues[2].trim() + if (uri.isEmpty()) return null + val ext = uri.substringAfterLast('.', "").lowercase() + if (ext in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || uri.contains("video/", ignoreCase = true)) { + return MdBlock.Video(uri = uri, alt = m.groupValues[1].trim()) + } + return null +} + +private fun fallthroughToParagraph( + lines: List, + start: Int, + blocks: MutableList, +): Int { + var i = start + val para = mutableListOf() + while ( + i < lines.size && + lines[i].isNotBlank() && + parseCodeFenceStart(lines[i]) == null && + !isValidHeading(lines[i]) && + !lines[i].startsWith(">") && + !LIST_ITEM_LINE_RE.matches(lines[i]) && + !isHorizontalRule(lines[i]) && + !isTableStart(lines, i) && + !isDefListStart(lines, i) && + tryParseDisplayMath(lines, i) == null && + FN_DEF_RE.matchAt(lines[i], 0) == null + ) { + para.add(lines[i]) + i++ + } + if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n"))) + return i +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt index 0ea45c65..cbe5511e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.ui.chat.markdown.parseBlocks import org.junit.Test import kotlin.system.measureTimeMillis diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index c531107b..6cd4de15 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -11,6 +11,10 @@ import com.m57.hermescontrol.theme.StatusRedContainer import com.m57.hermescontrol.theme.StatusYellow import com.m57.hermescontrol.theme.StatusYellowContainer import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.ui.chat.markdown.FnNote +import com.m57.hermescontrol.ui.chat.markdown.MdBlock +import com.m57.hermescontrol.ui.chat.markdown.TableAlign +import com.m57.hermescontrol.ui.chat.markdown.parseBlocks import com.m57.hermescontrol.util.BidiUtils import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse From e6e79be10d22c3dc49d9a6caeca72fa415dadc5a Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 18:24:00 +0300 Subject: [PATCH 19/42] feat(common): add ConfirmDialog and safeLaunchAction helper Add reusable ConfirmDialog with destructive action styling. Add safeLaunchAction to ViewModelExt for mutating calls with unit tests. --- .../hermescontrol/ui/common/ConfirmDialog.kt | 101 ++++++++++++++++++ .../hermescontrol/ui/common/ViewModelExt.kt | 27 +++++ .../ui/common/ViewModelExtTest.kt | 74 +++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/common/ConfirmDialog.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ConfirmDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ConfirmDialog.kt new file mode 100644 index 00000000..96ed49a0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ConfirmDialog.kt @@ -0,0 +1,101 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R + +/** + * Standard confirmation dialog across the app. + * Supports destructive actions with error styling, optional title icons, and optional extra body content. + */ +@Composable +fun ConfirmDialog( + title: String, + message: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + confirmText: String = stringResource(R.string.action_confirm), + dismissText: String = stringResource(R.string.action_cancel), + isDestructive: Boolean = false, + icon: ImageVector? = null, + extraContent: (@Composable () -> Unit)? = null, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { + if (icon != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = + if (isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(title) + } + } else { + Text(title) + } + }, + text = { + if (extraContent != null) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(message) + extraContent() + } + } else { + Text(message) + } + }, + confirmButton = { + if (isDestructive) { + Button( + onClick = onConfirm, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(confirmText) + } + } else { + Button(onClick = onConfirm) { + Text(confirmText) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(dismissText) + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt index 0ceb4520..56d2473e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt @@ -34,6 +34,33 @@ inline fun ViewModel.safeLaunchLoad( } } +/** + * Executes a mutating API call inside viewModelScope with onStart, onSuccess, onError, and onComplete lifecycle callbacks. + * Like safeLaunchLoad, avoids Dispatchers.IO hop to protect tests from dispatcher poisoning. + */ +inline fun ViewModel.safeLaunchAction( + currentJob: Job? = null, + crossinline onStart: () -> Unit = {}, + crossinline apiCall: suspend () -> NetworkResult, + crossinline onSuccess: (T) -> Unit = {}, + crossinline onError: (String) -> Unit = {}, + crossinline onComplete: () -> Unit = {}, +): Job { + if (currentJob?.isActive == true) return currentJob + onStart() + return viewModelScope.launch { + try { + val result = apiCall() + when (result) { + is NetworkResult.Success -> onSuccess(result.data) + is NetworkResult.Failure -> onError(result.error.message) + } + } finally { + onComplete() + } + } +} + /** * Collect a gateway change event ([com.m57.hermescontrol.data.ws.ChangeEvents]) * from [ChangeEventHub] and run a **silent** refresh: no loading spinner, no diff --git a/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt new file mode 100644 index 00000000..962e6b37 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt @@ -0,0 +1,74 @@ +package com.m57.hermescontrol.ui.common + +import androidx.lifecycle.ViewModel +import com.m57.hermescontrol.data.remote.NetworkError +import com.m57.hermescontrol.data.remote.NetworkResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ViewModelExtTest { + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private class TestViewModel : ViewModel() + + @Test + fun `safeLaunchAction triggers onStart, onSuccess and onComplete on success`() = + runTest(testDispatcher) { + val vm = TestViewModel() + var started = false + var successResult: String? = null + var completed = false + + vm.safeLaunchAction( + onStart = { started = true }, + apiCall = { NetworkResult.Success("ok") }, + onSuccess = { successResult = it }, + onError = {}, + onComplete = { completed = true }, + ) + + assertTrue(started) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("ok", successResult) + assertTrue(completed) + } + + @Test + fun `safeLaunchAction triggers onError and onComplete on failure`() = + runTest(testDispatcher) { + val vm = TestViewModel() + var errorMessage: String? = null + var completed = false + + vm.safeLaunchAction( + apiCall = { NetworkResult.Failure(NetworkError.Unknown("network error", Exception("boom"))) }, + onSuccess = {}, + onError = { errorMessage = it }, + onComplete = { completed = true }, + ) + + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("network error", errorMessage) + assertTrue(completed) + } +} From 02d99e159f2f83b62196c13cecc130c275959dff Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 18:27:20 +0300 Subject: [PATCH 20/42] fix(groupchat): adopt lifecycle state collection, add error/skeleton states, and fix duplicate test tag Use collectAsStateWithLifecycle, SkeletonListState, and ErrorState. Disambiguate composer stop button test tag. --- .../ui/bots/group/GroupChatScreen.kt | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index 948ed979..4835b2a9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -64,7 +64,6 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -92,6 +91,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalChatFontScale @@ -103,8 +103,10 @@ import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.formatTimestamp import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.util.BidiUtils import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -117,7 +119,7 @@ fun GroupChatScreen( modifier: Modifier = Modifier, viewModel: GroupChatViewModel = viewModel { GroupChatViewModel() }, ) { - val state by viewModel.uiState.collectAsState() + val state by viewModel.uiState.collectAsStateWithLifecycle() val listState = rememberLazyListState() val scrollScope = rememberCoroutineScope() val scrollController = rememberChatScrollController(listState, scrollScope) @@ -220,15 +222,23 @@ fun GroupChatScreen( ) { when { state.isLoading -> { - Box( + SkeletonListState( modifier = Modifier .weight(1f) .fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(modifier = Modifier.size(32.dp)) - } + ) + } + + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.setGroup(groupName) }, + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + ) } state.messages.isEmpty() -> { @@ -515,7 +525,7 @@ fun GroupChatScreen( modifier = Modifier .size(36.dp) - .testTag("group_chat_stop_button"), + .testTag("group_chat_composer_stop_button"), ) { Icon( imageVector = Icons.Filled.Stop, From 5ecc2e7d88ea3b16f4ac9e1b0402008bd70ea348 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 18:44:09 +0300 Subject: [PATCH 21/42] refactor(skills): decompose SkillsScreen into focused UI components Extract InstalledSkillsView, HubBrowseView, SkillCard, HubSkillCard, SourceChip, SkillScanViews, and SkillPreviewDialog into components/. --- .../hermescontrol/ui/skills/SkillsScreen.kt | 1045 +---------------- .../ui/skills/components/HubBrowseView.kt | 314 +++++ .../ui/skills/components/HubSkillCard.kt | 118 ++ .../skills/components/InstalledSkillsView.kt | 183 +++ .../ui/skills/components/SkillCard.kt | 161 +++ .../skills/components/SkillPreviewDialog.kt | 92 ++ .../ui/skills/components/SkillScanViews.kt | 270 +++++ .../ui/skills/components/SourceChip.kt | 56 + 8 files changed, 1197 insertions(+), 1042 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubBrowseView.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsView.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillPreviewDialog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillScanViews.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceChip.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index 8c52332d..9e58ccb2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -1,96 +1,37 @@ package com.m57.hermescontrol.ui.skills -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.CloudOff -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Extension -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Shield -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R -import com.m57.hermescontrol.data.model.HubSkill -import com.m57.hermescontrol.data.model.Skill -import com.m57.hermescontrol.data.model.SkillHubSource -import com.m57.hermescontrol.data.model.SkillScanFinding -import com.m57.hermescontrol.data.model.SkillScanResponse -import com.m57.hermescontrol.theme.LocalHermesStatusColors -import com.m57.hermescontrol.ui.common.DetailDialog -import com.m57.hermescontrol.ui.common.DetailRow -import com.m57.hermescontrol.ui.common.EmptyState -import com.m57.hermescontrol.ui.common.ErrorState -import com.m57.hermescontrol.ui.common.FilterChipRow import com.m57.hermescontrol.ui.common.HermesScaffold -import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.NavIcon -import com.m57.hermescontrol.ui.common.SearchBar -import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect -import com.m57.hermescontrol.ui.common.listItemSpacing -import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.skills.components.HubBrowseView +import com.m57.hermescontrol.ui.skills.components.InstalledSkillsView import com.m57.hermescontrol.ui.skills.components.SkillEditorDialog -import com.m57.hermescontrol.ui.skills.components.SourceBadge +import com.m57.hermescontrol.ui.skills.components.SkillPreviewDialog internal const val CATEGORY_ALL = "All" @@ -230,983 +171,3 @@ fun SkillsScreen( onClearToast = viewModel::clearToast, ) } - -// ── Installed Skills View ─────────────────────────────────────────────── - -@Composable -private fun InstalledSkillsView( - state: SkillsUiState, - query: String, - onQueryChange: (String) -> Unit, - selectedStatus: SkillFilter, - onStatusChange: (SkillFilter) -> Unit, - selectedCategory: String?, - onCategoryChange: (String) -> Unit, - onToggle: (Skill) -> Unit, - onEdit: (String) -> Unit, - onUninstall: (String) -> Unit, - onSetSourceFilter: (String?) -> Unit, - sourceFilter: String?, - isUninstalling: Boolean, - uninstallingSkillName: String?, - onRefresh: () -> Unit, - onBrowseHub: () -> Unit, -) { - var showDetail by remember { mutableStateOf(null) } - - val categories = - remember(state.skills) { - SkillListFilter.extractCategories(state.skills) - } - val sources = - remember(state.skills) { - SkillListFilter.extractSources(state.skills) - } - - val filteredSkills = - remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) { - SkillListFilter.filterSkills( - skills = state.skills, - query = query, - selectedStatus = selectedStatus, - selectedCategory = selectedCategory, - sourceFilter = sourceFilter, - ) - } - - Column(modifier = Modifier.fillMaxSize()) { - SearchBar( - query = query, - onQueryChange = onQueryChange, - placeholder = stringResource(R.string.skills_search_placeholder), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - ) - - FilterChipRow( - chips = SkillFilter.entries.toList(), - selectedChip = selectedStatus, - onChipSelected = onStatusChange, - chipLabel = { chip -> - Text( - text = - if (chip == SkillFilter.ALL_STATUSES) { - stringResource(R.string.skills_category_all) - } else { - stringResource(chip.labelRes) - }, - ) - }, - ) - - if (categories.isNotEmpty() || sources.isNotEmpty()) { - FilterChipRow( - chips = listOf(stringResource(R.string.skills_category_all)) + categories, - selectedChip = selectedCategory, - onChipSelected = onCategoryChange, - ) - if (sources.isNotEmpty()) { - FilterChipRow( - chips = listOf(null) + sources, - selectedChip = sourceFilter, - onChipSelected = onSetSourceFilter, - chipLabel = { source -> - Text( - text = source ?: stringResource(R.string.skills_source_all), - style = MaterialTheme.typography.labelSmall, - ) - }, - ) - } - } - - when { - state.isLoading -> { - SkeletonListState() - } - - state.errorMessage != null -> { - ErrorState( - message = state.errorMessage, - onRetry = onRefresh, - ) - } - - filteredSkills.isEmpty() -> { - EmptyState( - icon = Icons.Filled.Extension, - title = stringResource(R.string.skills_empty_message), - actionLabel = stringResource(R.string.empty_action_browse_hub), - onAction = onBrowseHub, - ) - } - - else -> { - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = listItemSpacing, - ) { - itemsIndexed( - filteredSkills, - key = { index, skill -> "${skill.name}:${skill.source ?: "unknown"}:$index" }, - ) { _, skill -> - SkillCard( - skill = skill, - onToggle = { onToggle(skill) }, - onAction = { onEdit(skill.name) }, - onUninstall = - if (skill.source == "hub") { - { onUninstall(skill.name) } - } else { - null - }, - isUninstalling = isUninstalling && uninstallingSkillName == skill.name, - onClick = { showDetail = skill }, - ) - } - } - } - } - } - - showDetail?.let { skill -> - DetailDialog( - title = skill.name, - rows = skill.toDetailRows(), - onDismiss = { showDetail = null }, - ) - } -} - -// ── Skill Card ───────────────────────────────────────────────────────── - -@Composable -private fun SkillCard( - skill: Skill, - onToggle: () -> Unit, - onAction: () -> Unit, - onUninstall: (() -> Unit)?, - isUninstalling: Boolean, - onClick: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).clickable(onClick = onClick), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f).padding(horizontal = 8.dp, vertical = 8.dp), - ) { - // ── Top row: name + source badge + toggle ── - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = skill.name, - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - skill.source?.let { source -> - Spacer(modifier = Modifier.width(6.dp)) - SourceBadge(source = source) - } - Spacer(modifier = Modifier.width(8.dp)) - Switch( - checked = skill.enabled, - onCheckedChange = { onToggle() }, - ) - } - - // ── Category ── - skill.category?.let { cat -> - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = cat, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // ── Description ── - skill.description?.let { desc -> - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = desc, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - - // ── Action buttons row ── - if (onUninstall != null) { - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onAction) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = stringResource(R.string.content_desc_edit_skill), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - IconButton( - onClick = onUninstall, - enabled = !isUninstalling, - ) { - if (isUninstalling) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - } else { - val statusColors = LocalHermesStatusColors.current - Icon( - imageVector = Icons.Filled.CloudOff, - contentDescription = stringResource(R.string.content_desc_uninstall_skill), - tint = statusColors.error, - ) - } - } - } - } else { - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - IconButton(onClick = onAction) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = stringResource(R.string.content_desc_edit_skill), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - } -} - -// ── Hub Browse View ───────────────────────────────────────────────────── - -@Composable -private fun HubBrowseView( - state: SkillsUiState, - hubQuery: String, - onHubQueryChange: (String) -> Unit, - onSearch: (String) -> Unit, - onClearSearch: () -> Unit, - onInstall: (String) -> Unit, - onPreviewHubSkill: (String) -> Unit, - onScanHubSkill: (String) -> Unit, - onClearHubScan: () -> Unit, - onLoadHubSources: () -> Unit, - isInstalling: Boolean, - installingSkillName: String?, -) { - var hubShowDetail by remember { mutableStateOf(null) } - - Column(modifier = Modifier.fillMaxSize()) { - OutlinedTextField( - value = hubQuery, - onValueChange = onHubQueryChange, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - placeholder = { Text(stringResource(R.string.skills_hub_search_placeholder)) }, - trailingIcon = { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (hubQuery.isNotEmpty()) { - IconButton(onClick = { - onHubQueryChange("") - onClearSearch() - }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(R.string.action_clear), - ) - } - } - IconButton( - onClick = { onSearch(hubQuery) }, - enabled = hubQuery.isNotBlank(), - ) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(R.string.skills_hub_search_button), - ) - } - } - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = - KeyboardActions( - onSearch = { - if (hubQuery.isNotBlank()) { - onSearch(hubQuery) - } - }, - ), - ) - - when { - state.isHubSearching -> { - SkeletonListState() - } - - state.hubSearchError != null -> { - ErrorState( - message = state.hubSearchError, - onRetry = { onSearch(hubQuery) }, - ) - } - - state.hubResults.isEmpty() && hubQuery.isNotBlank() && !state.isHubSearching -> { - EmptyState( - icon = Icons.Filled.Extension, - title = stringResource(R.string.skills_hub_empty_results), - ) - } - - state.hubResults.isNotEmpty() -> { - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = listItemSpacing, - ) { - itemsIndexed( - state.hubResults, - key = { index, hubSkill -> "${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" }, - ) { _, hubSkill -> - HubSkillCard( - hubSkill = hubSkill, - onInstall = { onInstall(hubSkill.name) }, - isInstalling = isInstalling && installingSkillName == hubSkill.name, - onClick = { - hubShowDetail = hubSkill - hubSkill.identifier?.let { onPreviewHubSkill(it) } - }, - ) - } - } - } - - else -> { - HubLandingView( - state = state, - onLoadHubSources = onLoadHubSources, - onInstall = onInstall, - isInstalling = isInstalling, - installingSkillName = installingSkillName, - onOpenDetail = { skill -> - hubShowDetail = skill - skill.identifier?.let { onPreviewHubSkill(it) } - }, - ) - } - } - } - - hubShowDetail?.let { hubSkill -> - val previewReady = state.hubPreviewIdentifier == hubSkill.identifier - val fullContent = - if (previewReady) { - state.hubPreviewContent ?: hubSkill.description - } else { - hubSkill.description - } - DetailDialog( - title = hubSkill.name, - rows = - listOf( - DetailRow(stringResource(R.string.detail_dialog_category), hubSkill.category), - DetailRow(stringResource(R.string.detail_dialog_source), hubSkill.source), - DetailRow(stringResource(R.string.detail_dialog_description), fullContent), - DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")), - DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel), - ), - actions = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (previewReady && state.isHubPreviewing) { - CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) - } - hubSkill.identifier?.let { identifier -> - SkillScanSection( - scanResult = - state.hubScanResult.takeIf { - state.hubScanIdentifier == identifier - }, - isScanning = - state.isHubScanning && - state.hubScanIdentifier == identifier, - scanError = - state.hubScanError.takeIf { - state.hubScanIdentifier == identifier - }, - onScan = { onScanHubSkill(identifier) }, - ) - } - } - }, - onDismiss = { - hubShowDetail = null - onClearHubScan() - }, - ) - } -} - -// ── Hub Landing (sources + featured) ─────────────────────────────────── - -@Composable -private fun HubLandingView( - state: SkillsUiState, - onLoadHubSources: () -> Unit, - onInstall: (String) -> Unit, - isInstalling: Boolean, - installingSkillName: String?, - onOpenDetail: (HubSkill) -> Unit, -) { - when { - state.isHubSourcesLoading -> { - SkeletonListState() - } - - state.hubSourcesError != null -> { - ErrorState( - message = state.hubSourcesError, - onRetry = onLoadHubSources, - ) - } - - state.hubSources.isEmpty() && state.hubFeatured.isEmpty() -> { - EmptyState( - icon = Icons.Filled.Extension, - title = stringResource(R.string.skills_hub_landing_hint), - ) - } - - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = listItemSpacing, - ) { - if (state.hubSources.isNotEmpty()) { - item(key = "hub_sources_header") { - Column( - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), - ) { - Text( - text = stringResource(R.string.skills_hub_sources_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = - Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - state.hubSources.forEach { source -> - SourceChip(source = source) - } - } - } - } - } - - if (state.hubFeatured.isNotEmpty()) { - item(key = "hub_featured_header") { - Column( - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), - ) { - Text( - text = stringResource(R.string.skills_hub_featured_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringResource(R.string.skills_hub_featured_subtitle), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - itemsIndexed( - state.hubFeatured, - key = { index, hubSkill -> - "featured:${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" - }, - ) { _, hubSkill -> - HubSkillCard( - hubSkill = hubSkill, - onInstall = { onInstall(hubSkill.name) }, - isInstalling = - isInstalling && installingSkillName == hubSkill.name, - onClick = { onOpenDetail(hubSkill) }, - ) - } - } - } - } - } -} - -@Composable -private fun SourceChip(source: SkillHubSource) { - val statusColors = LocalHermesStatusColors.current - val label = - buildString { - append(source.label ?: source.id) - when { - source.rateLimited == true -> { - append(" · ") - append(stringResource(R.string.skills_hub_source_rate_limited)) - } - - source.available == false -> { - append(" · ") - append(stringResource(R.string.skills_hub_source_offline)) - } - - source.searchable == false -> { - append(" · ") - append(stringResource(R.string.skills_hub_source_via_index)) - } - } - } - val color = - when { - source.rateLimited == true -> statusColors.warning - source.available == false -> statusColors.error - else -> MaterialTheme.colorScheme.primary - } - Surface( - color = color.copy(alpha = 0.12f), - shape = RoundedCornerShape(50), - ) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = color, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), - ) - } -} - -// ── Hub security scan ────────────────────────────────────────────────── - -@Composable -private fun SkillScanSection( - scanResult: SkillScanResponse?, - isScanning: Boolean, - scanError: String?, - onScan: () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = onScan, - enabled = !isScanning, - ) { - if (isScanning) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - imageVector = Icons.Filled.Shield, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text(stringResource(R.string.skills_hub_scan)) - } - } - - when { - isScanning -> { - Unit - } - - scanError != null -> { - Surface( - color = LocalHermesStatusColors.current.errorContainer, - shape = RoundedCornerShape(8.dp), - ) { - Text( - text = scanError, - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(12.dp), - ) - } - } - - scanResult != null -> { - ScanResultCard(result = scanResult) - } - } - } -} - -@Composable -private fun ScanResultCard(result: SkillScanResponse) { - val statusColors = LocalHermesStatusColors.current - val verdictColor = SkillScanPresentation.resolveVerdictColor(result.verdict, statusColors) - val policyLabel = - SkillScanPresentation.resolvePolicyLabelRes(result.policy)?.let { stringResource(it) } - ?: result.policy.orEmpty() - val policyColor = SkillScanPresentation.resolvePolicyColor(result.policy, statusColors) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // ── Verdict header ── - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Filled.Shield, - contentDescription = null, - tint = verdictColor, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.skills_hub_scan_verdict_label, result.verdict.orEmpty()), - style = MaterialTheme.typography.titleSmall, - color = verdictColor, - modifier = Modifier.weight(1f), - ) - Surface( - color = policyColor.copy(alpha = 0.15f), - shape = RoundedCornerShape(50), - ) { - Text( - text = policyLabel, - style = MaterialTheme.typography.labelSmall, - color = policyColor, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - ) - } - } - - // ── Trust + finding count ── - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (!result.trustLevel.isNullOrBlank()) { - Text( - text = result.trustLevel, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = - pluralStringResource( - R.plurals.skills_hub_scan_findings_count, - result.findings.size, - result.findings.size, - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // ── Severity tally ── - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - listOf("critical", "high", "medium", "low").forEach { sev -> - val count = result.severityCounts[sev] ?: 0 - if (count > 0) { - val sevColor = SkillScanPresentation.resolveSeverityColor(sev, statusColors) - Surface( - color = sevColor.copy(alpha = 0.15f), - shape = RoundedCornerShape(50), - ) { - Text( - text = "$count $sev", - style = MaterialTheme.typography.labelSmall, - color = sevColor, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - ) - } - } - } - if (result.findings.isEmpty()) { - Text( - text = stringResource(R.string.skills_hub_scan_no_findings), - style = MaterialTheme.typography.labelSmall, - color = statusColors.success, - ) - } - } - - // ── Policy reason ── - if (!result.policyReason.isNullOrBlank()) { - Text( - text = result.policyReason, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // ── Findings ── - if (result.findings.isNotEmpty()) { - HorizontalDivider() - result.findings.forEach { finding -> - ScanFindingRow(finding = finding) - } - } - } - } -} - -@Composable -private fun ScanFindingRow(finding: SkillScanFinding) { - val statusColors = LocalHermesStatusColors.current - val severityColor = SkillScanPresentation.resolveSeverityColor(finding.severity, statusColors) - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Surface( - color = severityColor.copy(alpha = 0.15f), - shape = RoundedCornerShape(4.dp), - ) { - Text( - text = finding.severity, - style = MaterialTheme.typography.labelSmall, - color = severityColor, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) - } - Text( - text = finding.category, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) - val location = - buildString { - finding.file?.let { append(it) } - finding.line?.let { append(":").append(it) } - } - if (location.isNotEmpty()) { - Text( - text = location, - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - finding.description?.let { description -> - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -// ── Hub Skill Card ───────────────────────────────────────────────────── - -@Composable -private fun HubSkillCard( - hubSkill: HubSkill, - onInstall: () -> Unit, - isInstalling: Boolean, - onClick: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).clickable(onClick = onClick), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), - ) { - // ── Top row: name ── - Text( - text = hubSkill.name, - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - - // ── Category ── - hubSkill.category?.let { cat -> - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = cat, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // ── Description ── - hubSkill.description?.let { desc -> - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = desc, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - - // ── Source badge + Install button ── - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - hubSkill.source?.let { source -> - SourceBadge(source = source) - Spacer(modifier = Modifier.width(8.dp)) - } - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = onInstall, - enabled = !isInstalling, - ) { - if (isInstalling) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - imageVector = Icons.Filled.CloudDownload, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.skills_hub_install)) - } - } - } - } - } -} - -// ── Skill Preview Dialog ─────────────────────────────────────────────── - -@Composable -fun SkillPreviewDialog( - skillName: String, - isLoading: Boolean, - content: String?, - onDismiss: () -> Unit, -) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Card( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) { - HermesScaffold( - title = { Text(stringResource(R.string.skills_preview_title, skillName)) }, - navigationIcon = NavIcon.Back(onBack = onDismiss), - ) { padding -> - Box( - modifier = - Modifier - .fillMaxSize(), - ) { - when { - isLoading -> { - LoadingState() - } - - content != null -> { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - ) { - Text( - text = content, - style = - MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - ), - ) - } - } - - else -> { - EmptyState( - icon = Icons.Filled.Extension, - title = stringResource(R.string.skills_preview_empty), - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubBrowseView.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubBrowseView.kt new file mode 100644 index 00000000..7d31f04a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubBrowseView.kt @@ -0,0 +1,314 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.HubSkill +import com.m57.hermescontrol.ui.common.DetailDialog +import com.m57.hermescontrol.ui.common.DetailRow +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.SkeletonListState +import com.m57.hermescontrol.ui.common.listItemSpacing +import com.m57.hermescontrol.ui.skills.SkillsUiState + +@Composable +fun HubBrowseView( + state: SkillsUiState, + hubQuery: String, + onHubQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + onClearSearch: () -> Unit, + onInstall: (String) -> Unit, + onPreviewHubSkill: (String) -> Unit, + onScanHubSkill: (String) -> Unit, + onClearHubScan: () -> Unit, + onLoadHubSources: () -> Unit, + isInstalling: Boolean, + installingSkillName: String?, +) { + var hubShowDetail by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxSize()) { + OutlinedTextField( + value = hubQuery, + onValueChange = onHubQueryChange, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + placeholder = { Text(stringResource(R.string.skills_hub_search_placeholder)) }, + trailingIcon = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (hubQuery.isNotEmpty()) { + IconButton(onClick = { + onHubQueryChange("") + onClearSearch() + }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.action_clear), + ) + } + } + IconButton( + onClick = { onSearch(hubQuery) }, + enabled = hubQuery.isNotBlank(), + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.skills_hub_search_button), + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = + KeyboardActions( + onSearch = { + if (hubQuery.isNotBlank()) { + onSearch(hubQuery) + } + }, + ), + ) + + when { + state.isHubSearching -> { + SkeletonListState() + } + + state.hubSearchError != null -> { + ErrorState( + message = state.hubSearchError, + onRetry = { onSearch(hubQuery) }, + ) + } + + state.hubResults.isEmpty() && hubQuery.isNotBlank() && !state.isHubSearching -> { + EmptyState( + icon = Icons.Filled.Extension, + title = stringResource(R.string.skills_hub_empty_results), + ) + } + + state.hubResults.isNotEmpty() -> { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = listItemSpacing, + ) { + itemsIndexed( + state.hubResults, + key = { index, hubSkill -> "${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" }, + ) { _, hubSkill -> + HubSkillCard( + hubSkill = hubSkill, + onInstall = { onInstall(hubSkill.name) }, + isInstalling = isInstalling && installingSkillName == hubSkill.name, + onClick = { + hubShowDetail = hubSkill + hubSkill.identifier?.let { onPreviewHubSkill(it) } + }, + ) + } + } + } + + else -> { + HubLandingView( + state = state, + onLoadHubSources = onLoadHubSources, + onInstall = onInstall, + isInstalling = isInstalling, + installingSkillName = installingSkillName, + onOpenDetail = { skill -> + hubShowDetail = skill + skill.identifier?.let { onPreviewHubSkill(it) } + }, + ) + } + } + } + + hubShowDetail?.let { hubSkill -> + val previewReady = state.hubPreviewIdentifier == hubSkill.identifier + val fullContent = + if (previewReady) { + state.hubPreviewContent ?: hubSkill.description + } else { + hubSkill.description + } + DetailDialog( + title = hubSkill.name, + rows = + listOf( + DetailRow(stringResource(R.string.detail_dialog_category), hubSkill.category), + DetailRow(stringResource(R.string.detail_dialog_source), hubSkill.source), + DetailRow(stringResource(R.string.detail_dialog_description), fullContent), + DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")), + DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel), + ), + actions = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (previewReady && state.isHubPreviewing) { + CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) + } + hubSkill.identifier?.let { identifier -> + SkillScanSection( + scanResult = + state.hubScanResult.takeIf { + state.hubScanIdentifier == identifier + }, + isScanning = + state.isHubScanning && + state.hubScanIdentifier == identifier, + scanError = + state.hubScanError.takeIf { + state.hubScanIdentifier == identifier + }, + onScan = { onScanHubSkill(identifier) }, + ) + } + } + }, + onDismiss = { + hubShowDetail = null + onClearHubScan() + }, + ) + } +} + +@Composable +fun HubLandingView( + state: SkillsUiState, + onLoadHubSources: () -> Unit, + onInstall: (String) -> Unit, + isInstalling: Boolean, + installingSkillName: String?, + onOpenDetail: (HubSkill) -> Unit, +) { + when { + state.isHubSourcesLoading -> { + SkeletonListState() + } + + state.hubSourcesError != null -> { + ErrorState( + message = state.hubSourcesError, + onRetry = onLoadHubSources, + ) + } + + state.hubSources.isEmpty() && state.hubFeatured.isEmpty() -> { + EmptyState( + icon = Icons.Filled.Extension, + title = stringResource(R.string.skills_hub_landing_hint), + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = listItemSpacing, + ) { + if (state.hubSources.isNotEmpty()) { + item(key = "hub_sources_header") { + Column( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), + ) { + Text( + text = stringResource(R.string.skills_hub_sources_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = + Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + state.hubSources.forEach { source -> + SourceChip(source = source) + } + } + } + } + } + + if (state.hubFeatured.isNotEmpty()) { + item(key = "hub_featured_header") { + Column( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), + ) { + Text( + text = stringResource(R.string.skills_hub_featured_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.skills_hub_featured_subtitle), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + itemsIndexed( + state.hubFeatured, + key = { index, hubSkill -> + "featured:${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" + }, + ) { _, hubSkill -> + HubSkillCard( + hubSkill = hubSkill, + onInstall = { onInstall(hubSkill.name) }, + isInstalling = + isInstalling && installingSkillName == hubSkill.name, + onClick = { onOpenDetail(hubSkill) }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillCard.kt new file mode 100644 index 00000000..11414133 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillCard.kt @@ -0,0 +1,118 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.HubSkill + +@Composable +fun HubSkillCard( + hubSkill: HubSkill, + onInstall: () -> Unit, + isInstalling: Boolean, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), + ) { + // ── Top row: name ── + Text( + text = hubSkill.name, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + // ── Category ── + hubSkill.category?.let { cat -> + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = cat, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── Description ── + hubSkill.description?.let { desc -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = desc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + // ── Source badge + Install button ── + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + hubSkill.source?.let { source -> + SourceBadge(source = source) + Spacer(modifier = Modifier.width(8.dp)) + } + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = onInstall, + enabled = !isInstalling, + ) { + if (isInstalling) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + imageVector = Icons.Filled.CloudDownload, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.skills_hub_install)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsView.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsView.kt new file mode 100644 index 00000000..abd94af1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsView.kt @@ -0,0 +1,183 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.Skill +import com.m57.hermescontrol.ui.common.DetailDialog +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.FilterChipRow +import com.m57.hermescontrol.ui.common.SearchBar +import com.m57.hermescontrol.ui.common.SkeletonListState +import com.m57.hermescontrol.ui.common.listItemSpacing +import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.skills.CATEGORY_ALL +import com.m57.hermescontrol.ui.skills.SkillFilter +import com.m57.hermescontrol.ui.skills.SkillListFilter +import com.m57.hermescontrol.ui.skills.SkillsUiState + +@Composable +fun InstalledSkillsView( + state: SkillsUiState, + query: String, + onQueryChange: (String) -> Unit, + selectedStatus: SkillFilter, + onStatusChange: (SkillFilter) -> Unit, + selectedCategory: String?, + onCategoryChange: (String) -> Unit, + onToggle: (Skill) -> Unit, + onEdit: (String) -> Unit, + onUninstall: (String) -> Unit, + onSetSourceFilter: (String?) -> Unit, + sourceFilter: String?, + isUninstalling: Boolean, + uninstallingSkillName: String?, + onRefresh: () -> Unit, + onBrowseHub: () -> Unit, +) { + var showDetail by remember { mutableStateOf(null) } + + val categories = + remember(state.skills) { + SkillListFilter.extractCategories(state.skills) + } + val sources = + remember(state.skills) { + SkillListFilter.extractSources(state.skills) + } + + val filteredSkills = + remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) { + SkillListFilter.filterSkills( + skills = state.skills, + query = query, + selectedStatus = selectedStatus, + selectedCategory = selectedCategory, + sourceFilter = sourceFilter, + ) + } + + Column(modifier = Modifier.fillMaxSize()) { + SearchBar( + query = query, + onQueryChange = onQueryChange, + placeholder = stringResource(R.string.skills_search_placeholder), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + + FilterChipRow( + chips = SkillFilter.entries.toList(), + selectedChip = selectedStatus, + onChipSelected = onStatusChange, + chipLabel = { chip -> + Text( + text = + if (chip == SkillFilter.ALL_STATUSES) { + stringResource(R.string.skills_category_all) + } else { + stringResource(chip.labelRes) + }, + ) + }, + ) + + if (categories.isNotEmpty() || sources.isNotEmpty()) { + FilterChipRow( + chips = listOf(stringResource(R.string.skills_category_all)) + categories, + selectedChip = selectedCategory, + onChipSelected = onCategoryChange, + ) + if (sources.isNotEmpty()) { + FilterChipRow( + chips = listOf(null) + sources, + selectedChip = sourceFilter, + onChipSelected = onSetSourceFilter, + chipLabel = { source -> + Text( + text = source ?: stringResource(R.string.skills_source_all), + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + } + } + + when { + state.isLoading -> { + SkeletonListState() + } + + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage, + onRetry = onRefresh, + ) + } + + filteredSkills.isEmpty() -> { + EmptyState( + icon = Icons.Filled.Extension, + title = stringResource(R.string.skills_empty_message), + actionLabel = stringResource(R.string.empty_action_browse_hub), + onAction = onBrowseHub, + ) + } + + else -> { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = listItemSpacing, + ) { + itemsIndexed( + filteredSkills, + key = { index, skill -> "${skill.name}:${skill.source ?: "unknown"}:$index" }, + ) { _, skill -> + SkillCard( + skill = skill, + onToggle = { onToggle(skill) }, + onAction = { onEdit(skill.name) }, + onUninstall = + if (skill.source == "hub") { + { onUninstall(skill.name) } + } else { + null + }, + isUninstalling = isUninstalling && uninstallingSkillName == skill.name, + onClick = { showDetail = skill }, + ) + } + } + } + } + } + + showDetail?.let { skill -> + DetailDialog( + title = skill.name, + rows = skill.toDetailRows(), + onDismiss = { showDetail = null }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillCard.kt new file mode 100644 index 00000000..fcedce17 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillCard.kt @@ -0,0 +1,161 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.Skill +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +@Composable +fun SkillCard( + skill: Skill, + onToggle: () -> Unit, + onAction: () -> Unit, + onUninstall: (() -> Unit)?, + isUninstalling: Boolean, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f).padding(horizontal = 8.dp, vertical = 8.dp), + ) { + // ── Top row: name + source badge + toggle ── + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = skill.name, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + skill.source?.let { source -> + Spacer(modifier = Modifier.width(6.dp)) + SourceBadge(source = source) + } + Spacer(modifier = Modifier.width(8.dp)) + Switch( + checked = skill.enabled, + onCheckedChange = { onToggle() }, + ) + } + + // ── Category ── + skill.category?.let { cat -> + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = cat, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── Description ── + skill.description?.let { desc -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = desc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + // ── Action buttons row ── + if (onUninstall != null) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onAction) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = stringResource(R.string.content_desc_edit_skill), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = onUninstall, + enabled = !isUninstalling, + ) { + if (isUninstalling) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + val statusColors = LocalHermesStatusColors.current + Icon( + imageVector = Icons.Filled.CloudOff, + contentDescription = stringResource(R.string.content_desc_uninstall_skill), + tint = statusColors.error, + ) + } + } + } + } else { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onAction) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = stringResource(R.string.content_desc_edit_skill), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillPreviewDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillPreviewDialog.kt new file mode 100644 index 00000000..a26bb55c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillPreviewDialog.kt @@ -0,0 +1,92 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.NavIcon + +@Composable +fun SkillPreviewDialog( + skillName: String, + isLoading: Boolean, + content: String?, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + HermesScaffold( + title = { Text(stringResource(R.string.skills_preview_title, skillName)) }, + navigationIcon = NavIcon.Back(onBack = onDismiss), + ) { padding -> + Box( + modifier = + Modifier + .fillMaxSize(), + ) { + when { + isLoading -> { + LoadingState() + } + + content != null -> { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = content, + style = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } + } + + else -> { + EmptyState( + icon = Icons.Filled.Extension, + title = stringResource(R.string.skills_preview_empty), + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillScanViews.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillScanViews.kt new file mode 100644 index 00000000..daecd21c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillScanViews.kt @@ -0,0 +1,270 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SkillScanFinding +import com.m57.hermescontrol.data.model.SkillScanResponse +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.skills.SkillScanPresentation + +@Composable +fun SkillScanSection( + scanResult: SkillScanResponse?, + isScanning: Boolean, + scanError: String?, + onScan: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = onScan, + enabled = !isScanning, + ) { + if (isScanning) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + imageVector = Icons.Filled.Shield, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.skills_hub_scan)) + } + } + + when { + isScanning -> { + Unit + } + + scanError != null -> { + Surface( + color = LocalHermesStatusColors.current.errorContainer, + shape = RoundedCornerShape(8.dp), + ) { + Text( + text = scanError, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + ) + } + } + + scanResult != null -> { + ScanResultCard(result = scanResult) + } + } + } +} + +@Composable +fun ScanResultCard(result: SkillScanResponse) { + val statusColors = LocalHermesStatusColors.current + val verdictColor = SkillScanPresentation.resolveVerdictColor(result.verdict, statusColors) + val policyLabel = + SkillScanPresentation.resolvePolicyLabelRes(result.policy)?.let { stringResource(it) } + ?: result.policy.orEmpty() + val policyColor = SkillScanPresentation.resolvePolicyColor(result.policy, statusColors) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // ── Verdict header ── + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Shield, + contentDescription = null, + tint = verdictColor, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.skills_hub_scan_verdict_label, result.verdict.orEmpty()), + style = MaterialTheme.typography.titleSmall, + color = verdictColor, + modifier = Modifier.weight(1f), + ) + Surface( + color = policyColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(50), + ) { + Text( + text = policyLabel, + style = MaterialTheme.typography.labelSmall, + color = policyColor, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } + + // ── Trust + finding count ── + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!result.trustLevel.isNullOrBlank()) { + Text( + text = result.trustLevel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = + pluralStringResource( + R.plurals.skills_hub_scan_findings_count, + result.findings.size, + result.findings.size, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── Severity tally ── + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + listOf("critical", "high", "medium", "low").forEach { sev -> + val count = result.severityCounts[sev] ?: 0 + if (count > 0) { + val sevColor = SkillScanPresentation.resolveSeverityColor(sev, statusColors) + Surface( + color = sevColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(50), + ) { + Text( + text = "$count $sev", + style = MaterialTheme.typography.labelSmall, + color = sevColor, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } + } + if (result.findings.isEmpty()) { + Text( + text = stringResource(R.string.skills_hub_scan_no_findings), + style = MaterialTheme.typography.labelSmall, + color = statusColors.success, + ) + } + } + + // ── Policy reason ── + if (!result.policyReason.isNullOrBlank()) { + Text( + text = result.policyReason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── Findings ── + if (result.findings.isNotEmpty()) { + HorizontalDivider() + result.findings.forEach { finding -> + ScanFindingRow(finding = finding) + } + } + } + } +} + +@Composable +fun ScanFindingRow(finding: SkillScanFinding) { + val statusColors = LocalHermesStatusColors.current + val severityColor = SkillScanPresentation.resolveSeverityColor(finding.severity, statusColors) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Surface( + color = severityColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(4.dp), + ) { + Text( + text = finding.severity, + style = MaterialTheme.typography.labelSmall, + color = severityColor, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 1.dp), + ) + } + Text( + text = finding.category, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + ) + val fileLocation = + buildString { + finding.file?.let { append(it) } + finding.line?.let { append(":").append(it) } + } + if (fileLocation.isNotEmpty()) { + Text( + text = fileLocation, + style = + MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + finding.description?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceChip.kt new file mode 100644 index 00000000..4412308d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceChip.kt @@ -0,0 +1,56 @@ +package com.m57.hermescontrol.ui.skills.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SkillHubSource +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +@Composable +fun SourceChip(source: SkillHubSource) { + val statusColors = LocalHermesStatusColors.current + val label = + buildString { + append(source.label ?: source.id) + when { + source.rateLimited == true -> { + append(" · ") + append(stringResource(R.string.skills_hub_source_rate_limited)) + } + + source.available == false -> { + append(" · ") + append(stringResource(R.string.skills_hub_source_offline)) + } + + source.searchable == false -> { + append(" · ") + append(stringResource(R.string.skills_hub_source_via_index)) + } + } + } + val color = + when { + source.rateLimited == true -> statusColors.warning + source.available == false -> statusColors.error + else -> MaterialTheme.colorScheme.primary + } + Surface( + color = color.copy(alpha = 0.12f), + shape = RoundedCornerShape(50), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = color, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + ) + } +} From 35dc7787023dfe3ccee2fd00fca650ee1a3bf493 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 19:07:03 +0300 Subject: [PATCH 22/42] refactor(system): decompose SystemScreen into focused section components Extract Host, Portal, Curator, Gateway, Credentials, Operations, Checkpoints, ShellHooks, ActionLog, and HookCreateDialog into components/. --- .../hermescontrol/ui/system/SystemScreen.kt | 1636 ++--------------- .../system/components/ActionButtonContent.kt | 55 + .../ui/system/components/ActionLogSection.kt | 135 ++ .../system/components/CheckpointsSection.kt | 78 + .../system/components/CredentialsSection.kt | 152 ++ .../ui/system/components/CuratorSection.kt | 136 ++ .../ui/system/components/GatewaySection.kt | 187 ++ .../ui/system/components/HookCreateDialog.kt | 121 ++ .../ui/system/components/HostSection.kt | 234 +++ .../ui/system/components/OperationsSection.kt | 392 ++++ .../ui/system/components/PortalSection.kt | 115 ++ .../ui/system/components/ShellHooksSection.kt | 60 + 12 files changed, 1794 insertions(+), 1507 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionButtonContent.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionLogSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/CheckpointsSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialsSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/CuratorSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCreateDialog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/HostSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/OperationsSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/PortalSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/components/ShellHooksSection.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index e8a36ea1..9b287edb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -5,55 +5,12 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Backup -import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.DeleteForever -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Devices -import androidx.compose.material.icons.filled.HealthAndSafety -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.Memory -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.Speed -import androidx.compose.material.icons.filled.Stop -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material.icons.filled.Update -import androidx.compose.material.icons.filled.UploadFile -import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -63,46 +20,44 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R -import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.chat.MediaImageStore import com.m57.hermescontrol.ui.common.ActionProgressDialog +import com.m57.hermescontrol.ui.common.ConfirmDialog import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold -import com.m57.hermescontrol.ui.common.InfoRow import com.m57.hermescontrol.ui.common.NavIcon -import com.m57.hermescontrol.ui.common.SectionHeader import com.m57.hermescontrol.ui.common.SkeletonListState -import com.m57.hermescontrol.ui.common.StatCard -import com.m57.hermescontrol.ui.common.StatusBadge -import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect -import com.m57.hermescontrol.ui.system.components.CredentialEntryRow -import com.m57.hermescontrol.ui.system.components.HookCard -import com.m57.hermescontrol.util.formatBytes -import com.m57.hermescontrol.util.formatDuration +import com.m57.hermescontrol.ui.system.components.HookCreateDialog +import com.m57.hermescontrol.ui.system.components.actionLogSection +import com.m57.hermescontrol.ui.system.components.checkpointsSection +import com.m57.hermescontrol.ui.system.components.credentialsSection +import com.m57.hermescontrol.ui.system.components.curatorSection +import com.m57.hermescontrol.ui.system.components.gatewaySection +import com.m57.hermescontrol.ui.system.components.hostSection +import com.m57.hermescontrol.ui.system.components.operationsSection +import com.m57.hermescontrol.ui.system.components.portalSection +import com.m57.hermescontrol.ui.system.components.shellHooksSection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +/** Staged SAF-picked backup archive for the restore flow (issue #786). */ +private data class PendingImport( + val uri: Uri, + val name: String, + val mime: String, +) + @Composable fun SystemScreen( modifier: Modifier = Modifier, @@ -110,21 +65,18 @@ fun SystemScreen( viewModel: SystemViewModel? = null, ) { val app = LocalContext.current.applicationContext as Application - val viewModel = viewModel ?: viewModel { SystemViewModel(app) } - val state by viewModel.uiState.collectAsStateWithLifecycle() + val resolvedViewModel = viewModel ?: viewModel { SystemViewModel(app) } + val state by resolvedViewModel.uiState.collectAsStateWithLifecycle() val spacing = LocalSpacing.current - val statusColors = LocalHermesStatusColors.current val uriHandler = LocalUriHandler.current LaunchedEffect(Unit) { - viewModel.loadAll() + resolvedViewModel.loadAll() } - ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + ToastEffect(toastMessage = state.toastMessage, onClearToast = resolvedViewModel::clearToast) // ── Backup import (issue #786) ──────────────────────────────────────── - // SAF file picker → stage uri/name/mime → confirm dialog → read bytes on - // IO → multipart upload via SystemViewModel.importArchive. val context = LocalContext.current val importScope = rememberCoroutineScope() var pendingImport by remember { mutableStateOf(null) } @@ -147,9 +99,9 @@ fun SystemScreen( meta.fold( onSuccess = { pendingImport = it - viewModel.setImportFile(it.name) + resolvedViewModel.setImportFile(it.name) }, - onFailure = { viewModel.showToast("Could not read selected file: ${it.message}") }, + onFailure = { resolvedViewModel.showToast("Could not read selected file: ${it.message}") }, ) } } @@ -158,132 +110,127 @@ fun SystemScreen( if (state.updateConfirmOpen) { AlertDialog( - onDismissRequest = viewModel::closeUpdateConfirm, + onDismissRequest = resolvedViewModel::closeUpdateConfirm, title = { Text(stringResource(R.string.system_update_confirm_title)) }, text = { Text(stringResource(R.string.system_update_confirm_desc)) }, confirmButton = { TextButton(onClick = { - viewModel.closeUpdateConfirm() - viewModel.applyUpdate() + resolvedViewModel.closeUpdateConfirm() + resolvedViewModel.applyUpdate() }) { Text(stringResource(R.string.system_confirm_update_now)) } }, dismissButton = { - TextButton(onClick = viewModel::closeUpdateConfirm) { + TextButton(onClick = resolvedViewModel::closeUpdateConfirm) { Text(stringResource(R.string.system_confirm_cancel)) } }, ) } - // Update progress popup (issue #863): replaces the old bottom-of-screen - // action log for the update flow — spinner + log tail while running, - // success/failure state once it exits. ActionProgressDialog( - controller = viewModel.actionProgress, + controller = resolvedViewModel.actionProgress, title = stringResource(R.string.system_update_progress_title), ) - // Credential remove confirmation + // Credential remove confirmation using ConfirmDialog var credToRemove by remember { mutableStateOf?>(null) } - if (credToRemove != null) { - AlertDialog( - onDismissRequest = { credToRemove = null }, - title = { Text(stringResource(R.string.system_credentials_remove_confirm_title)) }, - text = { Text(stringResource(R.string.system_credentials_remove_confirm_desc)) }, - confirmButton = { - TextButton(onClick = { - credToRemove?.let { (p, i) -> viewModel.removeCredential(p, i) } - credToRemove = null - }) { - Text(stringResource(R.string.action_delete)) - } - }, - dismissButton = { - TextButton(onClick = { credToRemove = null }) { - Text(stringResource(R.string.system_confirm_cancel)) - } + credToRemove?.let { (provider, index) -> + ConfirmDialog( + title = stringResource(R.string.system_credentials_remove_confirm_title), + message = stringResource(R.string.system_credentials_remove_confirm_desc), + onConfirm = { + resolvedViewModel.removeCredential(provider, index) + credToRemove = null }, + onDismiss = { credToRemove = null }, + confirmText = stringResource(R.string.action_delete), + isDestructive = true, + icon = Icons.Filled.Delete, ) } // Prune checkpoints confirmation var pruneConfirm by remember { mutableStateOf(false) } if (pruneConfirm) { - AlertDialog( - onDismissRequest = { pruneConfirm = false }, - title = { Text(stringResource(R.string.system_checkpoints_prune_confirm_title)) }, - text = { Text(stringResource(R.string.system_checkpoints_prune_confirm_desc)) }, - confirmButton = { - TextButton(onClick = { - pruneConfirm = false - viewModel.pruneCheckpoints() - }) { - Text(stringResource(R.string.action_ok)) - } - }, - dismissButton = { - TextButton(onClick = { pruneConfirm = false }) { - Text(stringResource(R.string.system_confirm_cancel)) - } + ConfirmDialog( + title = stringResource(R.string.system_checkpoints_prune_confirm_title), + message = stringResource(R.string.system_checkpoints_prune_confirm_desc), + onConfirm = { + pruneConfirm = false + resolvedViewModel.pruneCheckpoints() }, + onDismiss = { pruneConfirm = false }, + confirmText = stringResource(R.string.action_ok), + isDestructive = true, ) } - // Import / Restore confirmation + // Restore confirmation var importConfirm by remember { mutableStateOf(false) } if (importConfirm) { - AlertDialog( - onDismissRequest = { importConfirm = false }, - title = { Text(stringResource(R.string.system_op_import_confirm_title)) }, - text = { Text(stringResource(R.string.system_op_import_confirm_desc)) }, - confirmButton = { - TextButton(onClick = { - importConfirm = false - val pending = pendingImport - pendingImport = null - if (pending != null) { - importScope.launch { - val read = - withContext(Dispatchers.IO) { - runCatching { - context.contentResolver.openInputStream(pending.uri)?.use { it.readBytes() } - } + ConfirmDialog( + title = stringResource(R.string.system_op_import_confirm_title), + message = stringResource(R.string.system_op_import_confirm_desc), + onConfirm = { + importConfirm = false + val pending = pendingImport + pendingImport = null + if (pending != null) { + importScope.launch { + val read = + withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openInputStream(pending.uri)?.use { it.readBytes() } } - read.fold( - onSuccess = { bytes -> - if (bytes != null) { - viewModel.importArchive(pending.name, bytes, pending.mime) - } else { - viewModel.showToast("Could not read selected file") - } - }, - onFailure = { - viewModel.showToast("Could not read selected file: ${it.message}") - }, - ) - } + } + read.fold( + onSuccess = { bytes -> + if (bytes != null) { + resolvedViewModel.importArchive(pending.name, bytes, pending.mime) + } else { + resolvedViewModel.showToast("Could not read selected file") + } + }, + onFailure = { + resolvedViewModel.showToast("Could not read selected file: ${it.message}") + }, + ) } - }) { - Text(stringResource(R.string.system_confirm_restore)) - } - }, - dismissButton = { - TextButton(onClick = { importConfirm = false }) { - Text(stringResource(R.string.system_confirm_cancel)) } }, + onDismiss = { importConfirm = false }, + confirmText = stringResource(R.string.system_confirm_restore), + isDestructive = true, ) } + // Hook creation modal (hoisted out of LazyColumn item) + HookCreateDialog( + isOpen = state.hookModalOpen, + event = state.hookEvent, + command = state.hookCommand, + matcher = state.hookMatcher, + timeout = state.hookTimeout, + approve = state.hookApprove, + isCreating = state.creatingHook, + onEventChange = resolvedViewModel::updateHookEvent, + onCommandChange = resolvedViewModel::updateHookCommand, + onMatcherChange = resolvedViewModel::updateHookMatcher, + onTimeoutChange = resolvedViewModel::updateHookTimeout, + onApproveChange = resolvedViewModel::updateHookApprove, + onCreate = resolvedViewModel::createHook, + onDismiss = resolvedViewModel::toggleHookModal, + ) + HermesScaffold( modifier = modifier, title = { Text(stringResource(R.string.screen_system)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, - onRefresh = { viewModel.loadAll() }, - ) { + onRefresh = { resolvedViewModel.loadAll() }, + ) { padding -> when { state.isLoading && state.stats == null && state.doctorReport == null && state.status == null -> { @@ -293,7 +240,7 @@ fun SystemScreen( state.errorMessage != null -> { ErrorState( message = state.errorMessage ?: stringResource(R.string.error_unknown), - onRetry = { viewModel.loadAll() }, + onRetry = { resolvedViewModel.loadAll() }, ) } @@ -308,35 +255,34 @@ fun SystemScreen( LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = - androidx.compose.foundation.layout.PaddingValues( + PaddingValues( horizontal = spacing.md, vertical = spacing.sm, ), verticalArrangement = Arrangement.spacedBy(spacing.sm), ) { - // ── 1. Host ──────────────────────────────────────────── - hostSection(state, spacing, statusColors, viewModel) + // ── 1. Host ── + hostSection(state, spacing, resolvedViewModel) - // ── 2. Portal ────────────────────────────────────────── + // ── 2. Portal ── portalSection(state, spacing, uriHandler) - // ── 3. Curator ───────────────────────────────────────── - curatorSection(state, spacing, statusColors, viewModel) + // ── 3. Curator ── + curatorSection(state, spacing, resolvedViewModel) - // ── 4. Gateway ───────────────────────────────────────── - gatewaySection(state, spacing, statusColors, viewModel) + // ── 4. Gateway ── + gatewaySection(state, spacing, resolvedViewModel) - // ── 6. Credentials ───────────────────────────────────── - credentialsSection(state, spacing, viewModel, credToRemove, { credToRemove = it }) + // ── 6. Credentials ── + credentialsSection(state, spacing, resolvedViewModel) { credToRemove = it } - // ── 7. Operations ────────────────────────────────────── + // ── 7. Operations ── operationsSection( - state, - spacing, - viewModel, - importConfirm, - { importConfirm = it }, - { + state = state, + spacing = spacing, + viewModel = resolvedViewModel, + onImportConfirm = { importConfirm = it }, + onImportPick = { importPickerLauncher.launch( arrayOf( "application/zip", @@ -346,8 +292,8 @@ fun SystemScreen( ), ) }, - { - viewModel.downloadBackup { body, fileName -> + downloadBackup = { + resolvedViewModel.downloadBackup { body, fileName -> importScope.launch { val uri = withContext(Dispatchers.IO) { @@ -362,1351 +308,27 @@ fun SystemScreen( }.getOrNull() } if (uri != null) { - viewModel.showToast("Backup saved to Downloads") + resolvedViewModel.showToast("Backup saved to Downloads") } else { - viewModel.showToast("Could not save backup to Downloads") + resolvedViewModel.showToast("Could not save backup to Downloads") } } } }, ) - // ── 8. Checkpoints ───────────────────────────────────── - checkpointsSection(state, spacing, pruneConfirm, { pruneConfirm = it }) + // ── 8. Checkpoints ── + checkpointsSection(state, spacing) { pruneConfirm = it } - // ── 9. Shell Hooks ───────────────────────────────────── - shellHooksSection(state, spacing, viewModel) + // ── 9. Shell Hooks ── + shellHooksSection(state, spacing, resolvedViewModel) - // ── 10. Action Log ───────────────────────────────────── + // ── 10. Action Log ── if (state.activeAction != null) { - actionLogSection(state, spacing, viewModel) + actionLogSection(state, spacing, resolvedViewModel) } } } } } } - -// ── Section composables ───────────────────────────────────────────────── - -@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) -private fun LazyListScope.hostSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - statusColors: com.m57.hermescontrol.theme.HermesStatusColors, - viewModel: SystemViewModel, -) { - state.stats?.let { stats -> - item { - SectionHeader(title = stringResource(R.string.system_sec_host)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // OS, Arch, Hostname - stats.os?.let { InfoRow(stringResource(R.string.system_label_os), it) } - stats.arch?.let { InfoRow(stringResource(R.string.system_label_arch), it) } - stats.hostname?.let { InfoRow(stringResource(R.string.system_label_hostname), it) } - stats.python_version?.let { python -> - val impl = stats.python_impl?.let { " ($it)" } ?: "" - InfoRow(stringResource(R.string.system_label_python), "$python$impl") - } - - // Hermes version + update badge - stats.hermes_version?.let { ver -> - val updateBadge = - state.updateInfo?.let { info -> - when { - info.update_available == true && info.behind != null && info.behind > 0 -> { - " (${stringResource( - R.string.system_version_update_available, - )}: ${stringResource(R.string.system_version_behind, info.behind)})" - } - - info.update_available == true -> { - " (${stringResource( - R.string.system_version_update_available, - )})" - } - - else -> { - " (${stringResource(R.string.system_version_latest)})" - } - } - } ?: "" - InfoRow(stringResource(R.string.system_label_hermes_version), "$ver$updateBadge") - } - - // Update buttons - Row( - modifier = Modifier.fillMaxWidth().padding(top = spacing.sm), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - OutlinedButton( - onClick = { viewModel.checkForUpdate(false) }, - enabled = !state.checkingUpdate, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - if (state.checkingUpdate) { - ActionButtonContent( - icon = null, - text = stringResource(R.string.system_action_check_updates), - loading = true, - ) - } else { - ActionButtonContent( - icon = Icons.Filled.Update, - text = stringResource(R.string.system_action_check_updates), - ) - } - } - state.updateInfo?.let { info -> - if (info.update_available == true && info.can_apply == true) { - Button( - onClick = { viewModel.openUpdateConfirm() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.PlayArrow, - text = stringResource(R.string.system_action_update_now), - ) - } - } - } - } - - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - - // CPU / Memory / Disk stat cards - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - stats.cpu_percent?.let { cpu -> - StatCard( - label = stringResource(R.string.system_label_cpu), - value = "${cpu.toInt()}%", - icon = Icons.Filled.Speed, - modifier = Modifier.weight(1f), - ) - } - stats.memory?.percent?.let { mem -> - StatCard( - label = stringResource(R.string.system_label_memory), - value = "${mem.toInt()}%", - icon = Icons.Filled.Memory, - modifier = Modifier.weight(1f), - ) - } - stats.disk?.percent?.let { disk -> - StatCard( - label = stringResource(R.string.system_label_disk), - value = "${disk.toInt()}%", - icon = Icons.Filled.Storage, - modifier = Modifier.weight(1f), - ) - } - } - - // Memory details - stats.memory?.let { mem -> - InfoRow( - label = stringResource(R.string.system_label_memory), - value = - stringResource( - R.string.system_label_used_total_percent, - formatBytes(mem.used ?: 0L), - formatBytes(mem.total ?: 0L), - "${(mem.percent ?: 0.0).toInt()}", - ), - ) - } - - // Disk details - stats.disk?.let { disk -> - InfoRow( - label = stringResource(R.string.system_label_disk), - value = - stringResource( - R.string.system_label_used_total_percent, - formatBytes(disk.used ?: 0L), - formatBytes(disk.total ?: 0L), - "${(disk.percent ?: 0.0).toInt()}", - ), - ) - } - - // Uptime - stats.uptime_seconds?.let { secs -> - InfoRow( - label = stringResource(R.string.system_label_uptime), - value = formatDuration(secs), - ) - } - - // Load avg - stats.load_avg?.let { loads -> - if (loads.isNotEmpty()) { - val coresText = - stats.cpu_count?.let { - " ($it ${stringResource( - R.string.system_label_cores, - )})" - } ?: "" - InfoRow( - label = stringResource(R.string.system_label_load_avg), - value = loads.joinToString(", ") { "%.2f".format(it) } + coresText, - ) - } - } ?: stats.cpu_count?.let { cores -> - InfoRow( - label = stringResource(R.string.system_label_cpu), - value = "$cores ${stringResource(R.string.system_label_cores)}", - ) - } - - // psutil warning - if (stats.psutil == false) { - Spacer(modifier = Modifier.height(spacing.sm)) - StatusBadge( - text = stringResource(R.string.system_psutil_warning), - status = StatusBadgeType.WARNING, - ) - } - } - } - } - } -} - -private fun LazyListScope.portalSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - uriHandler: androidx.compose.ui.platform.UriHandler, -) { - state.portal?.let { portal -> - item { - SectionHeader(title = stringResource(R.string.system_sec_portal)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Login status - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - val loggedIn = portal.logged_in == true - StatusBadge( - text = - if (loggedIn) { - stringResource(R.string.system_status_logged_in) - } else { - stringResource(R.string.system_status_not_logged_in) - }, - status = if (loggedIn) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING, - ) - if (!loggedIn) { - Text( - text = stringResource(R.string.system_portal_login_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - // Provider - portal.provider?.let { provider -> - InfoRow( - label = stringResource(R.string.system_portal_provider, provider), - value = "", - ) - } - - // Manage subscription link - portal.subscription_url?.let { url -> - TextButton( - onClick = { uriHandler.openUri(url) }, - modifier = Modifier.padding(top = spacing.xs), - ) { - Icon(Icons.Filled.Link, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - Text( - stringResource(R.string.system_portal_manage_subscription), - maxLines = 1, - softWrap = false, - ) - } - } - - // Feature routing - portal.features?.let { features -> - if (features.isNotEmpty()) { - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - features.forEach { feature -> - InfoRow( - label = feature.label ?: stringResource(R.string.system_portal_feature_routing), - value = feature.state ?: "", - ) - } - } - } - } - } - } - } -} - -private fun LazyListScope.curatorSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - statusColors: com.m57.hermescontrol.theme.HermesStatusColors, - viewModel: SystemViewModel, -) { - state.curator?.let { curator -> - item { - SectionHeader(title = stringResource(R.string.system_sec_curator)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Status - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - val statusText: String - val statusType: StatusBadgeType - when { - curator.enabled != true -> { - statusText = stringResource(R.string.system_status_disabled) - statusType = StatusBadgeType.ERROR - } - - curator.paused == true -> { - statusText = stringResource(R.string.system_status_paused) - statusType = StatusBadgeType.WARNING - } - - else -> { - statusText = stringResource(R.string.system_status_active) - statusType = StatusBadgeType.SUCCESS - } - } - StatusBadge(text = statusText, status = statusType) - } - - // Interval info - curator.interval_hours?.let { hrs -> - InfoRow( - label = stringResource(R.string.system_curator_interval, hrs), - value = "", - ) - Spacer(modifier = Modifier.height(spacing.xs)) - } - - // Last run - curator.last_run_at?.let { lastRun -> - InfoRow( - label = stringResource(R.string.system_curator_last_run, lastRun), - value = "", - ) - } ?: InfoRow( - label = stringResource(R.string.system_curator_never_run), - value = "", - ) - - // Actions - if (curator.enabled == true) { - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - OutlinedButton( - onClick = { viewModel.toggleCuratorPaused() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - if (curator.paused == true) { - ActionButtonContent( - icon = Icons.Filled.PlayArrow, - text = stringResource(R.string.system_curator_resume), - ) - } else { - ActionButtonContent( - icon = Icons.Filled.Pause, - text = stringResource(R.string.system_curator_pause), - ) - } - } - Button( - onClick = { viewModel.runCuratorNow() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Refresh, - text = stringResource(R.string.system_curator_run_now), - ) - } - } - } - } - } - } - } -} - -private fun LazyListScope.gatewaySection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - statusColors: com.m57.hermescontrol.theme.HermesStatusColors, - viewModel: SystemViewModel, -) { - state.status?.let { status -> - item { - SectionHeader(title = stringResource(R.string.system_sec_gateway)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Status badge + version - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - val isRunning = status.gateway_running == true - StatusBadge( - text = - if (isRunning) { - stringResource(R.string.system_status_running) - } else { - stringResource(R.string.system_status_stopped) - }, - status = if (isRunning) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, - ) - } - } - - // Version · Sessions · PID - status.version?.let { ver -> - val parts = mutableListOf(ver) - status.active_sessions?.let { parts.add("$it session(s)") } - state.doctorReport?.let { report -> - if (report.ok && report.pid != null) { - parts.add("pid ${report.pid}") - } - } - InfoRow( - label = stringResource(R.string.system_label_hermes_version), - value = parts.joinToString(" · "), - ) - } - - // Auth required - status.auth_required?.let { auth -> - InfoRow( - label = stringResource(R.string.system_auth_required), - value = if (auth) "yes" else "no", - ) - } - - // Active platforms - status.gateway_platforms?.let { platforms -> - if (platforms.isNotEmpty()) { - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - platforms.forEach { (name, platform) -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - Icon( - Icons.Filled.Devices, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = name, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - platform?.state?.let { platformState -> - StatusBadge( - text = platformState, - status = - when (platformState.lowercase()) { - "connected", "running", "ready" -> StatusBadgeType.SUCCESS - "error", "disconnected", "failed" -> StatusBadgeType.ERROR - else -> StatusBadgeType.NEUTRAL - }, - ) - } - } - } - } - } - - // Action buttons - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - val isRunning = status.gateway_running == true - if (!isRunning) { - Button( - onClick = { viewModel.startGateway() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.PlayArrow, - text = stringResource(R.string.system_gateway_start), - ) - } - } - if (isRunning) { - Button( - onClick = { viewModel.restartGateway() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.RestartAlt, - text = stringResource(R.string.system_gateway_restart), - ) - } - OutlinedButton( - onClick = { viewModel.stopGateway() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Stop, - text = stringResource(R.string.system_gateway_stop), - iconTint = MaterialTheme.colorScheme.error, - textColor = MaterialTheme.colorScheme.error, - ) - } - } - } - } - } - } - } -} - -private fun LazyListScope.credentialsSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - viewModel: SystemViewModel, - credToRemove: Pair?, - onRemoveRequest: (Pair) -> Unit, -) { - item { - SectionHeader( - title = stringResource(R.string.system_sec_credentials), - trailing = { - val hasCreds = state.credentials.any { it.entries?.isNotEmpty() == true } - if (hasCreds) { - Text( - text = "${state.credentials.sumOf { it.entries?.size ?: 0 }} key(s)", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - ) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Add credential form - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - OutlinedTextField( - value = state.credProvider, - onValueChange = viewModel::updateCredProvider, - label = { Text(stringResource(R.string.system_credentials_provider)) }, - singleLine = true, - modifier = Modifier.weight(1f), - textStyle = MaterialTheme.typography.bodySmall, - ) - } - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - OutlinedTextField( - value = state.credKey, - onValueChange = viewModel::updateCredKey, - label = { Text(stringResource(R.string.system_credentials_api_key)) }, - singleLine = true, - modifier = Modifier.weight(1f), - textStyle = MaterialTheme.typography.bodySmall, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - ) - OutlinedTextField( - value = state.credLabel, - onValueChange = viewModel::updateCredLabel, - label = { Text(stringResource(R.string.system_credentials_label)) }, - singleLine = true, - modifier = Modifier.weight(1f), - textStyle = MaterialTheme.typography.bodySmall, - ) - } - Spacer(modifier = Modifier.height(spacing.sm)) - Button( - onClick = { viewModel.addCredential() }, - enabled = !state.addingCred && state.credKey.isNotBlank(), - modifier = Modifier.fillMaxWidth(), - ) { - if (state.addingCred) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.size(spacing.sm)) - } else { - Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - } - Text(stringResource(R.string.system_credentials_add_key), maxLines = 1, softWrap = false) - } - - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - - // Provider list with entries - if (state.credentials.isEmpty()) { - Text( - text = stringResource(R.string.system_credentials_empty), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = spacing.sm), - ) - } - - state.credentials.forEach { provider -> - Text( - text = provider.provider ?: "?", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(vertical = spacing.xs), - ) - provider.entries?.forEach { entry -> - CredentialEntryRow( - entry = entry, - providerName = provider.provider ?: "", - onRemove = { onRemoveRequest(Pair(provider.provider ?: "", entry.index ?: 0)) }, - spacing = spacing, - ) - } - } - } - } - } -} - -private fun LazyListScope.operationsSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - viewModel: SystemViewModel, - importConfirm: Boolean, - onImportConfirm: (Boolean) -> Unit, - onImportPick: () -> Unit, - downloadBackup: () -> Unit, -) { - item { - SectionHeader(title = stringResource(R.string.system_sec_operations)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Operation buttons row 1 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - FilledTonalButton( - onClick = { - // loadAll already fetches doctor report, just call it - viewModel.loadAll() - }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.HealthAndSafety, - text = stringResource(R.string.system_op_doctor), - ) - } - FilledTonalButton( - onClick = { viewModel.runSecurityAudit() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.VerifiedUser, - text = stringResource(R.string.system_op_security_audit), - ) - } - FilledTonalButton( - onClick = { viewModel.runUpdateSkills() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Refresh, - text = stringResource(R.string.system_op_update_skills), - ) - } - } - - Spacer(modifier = Modifier.height(spacing.sm)) - - // Operation buttons row 2 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - FilledTonalButton( - onClick = { viewModel.runPromptSize() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.HealthAndSafety, - text = stringResource(R.string.system_op_prompt_size), - ) - } - FilledTonalButton( - onClick = { viewModel.runDump() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Build, - text = stringResource(R.string.system_op_dump), - ) - } - FilledTonalButton( - onClick = { viewModel.runConfigMigrate() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Build, - text = stringResource(R.string.system_op_config_migrate), - ) - } - } - - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - - // Backup section - Text( - text = stringResource(R.string.system_sec_backup), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - Button( - onClick = { viewModel.triggerBackup() }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Backup, - text = stringResource(R.string.system_op_backup_create), - ) - } - OutlinedButton( - onClick = downloadBackup, - enabled = state.backupArchive != null && !state.isDownloading, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - if (state.isDownloading) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.size(spacing.xs)) - } else { - Icon( - Icons.Filled.CloudDownload, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.size(spacing.xs)) - } - Text( - text = stringResource(R.string.system_op_backup_download), - maxLines = 1, - softWrap = false, - ) - } - } - - // Restore from picked archive (issue #786: SAF picker → upload) - Spacer(modifier = Modifier.height(spacing.sm)) - Text( - text = stringResource(R.string.system_sec_restore), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - - OutlinedButton( - onClick = onImportPick, - enabled = !state.isImporting, - modifier = Modifier.fillMaxWidth(), - contentPadding = actionButtonPadding, - ) { - Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - Text( - text = stringResource(R.string.system_op_restore_pick), - maxLines = 1, - softWrap = false, - ) - } - - state.importFileName?.let { name -> - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - Icon( - imageVector = Icons.Filled.Description, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - Spacer(modifier = Modifier.height(spacing.sm)) - - Button( - onClick = { onImportConfirm(true) }, - enabled = state.importFileName != null && !state.isImporting, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - modifier = Modifier.fillMaxWidth(), - ) { - if (state.isImporting) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onError, - ) - Spacer(modifier = Modifier.size(spacing.xs)) - } else { - Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - } - Text(stringResource(R.string.system_op_restore_upload), maxLines = 1, softWrap = false) - } - - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - - // Debug share section - SectionHeader(title = stringResource(R.string.system_sec_debug_share)) - Spacer(modifier = Modifier.height(spacing.sm)) - - Text( - text = stringResource(R.string.system_debug_share_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - - androidx.compose.material3.Switch( - checked = state.shareRedact, - onCheckedChange = { viewModel.toggleShareRedact() }, - ) - Spacer(modifier = Modifier.size(spacing.sm)) - Text( - text = stringResource(R.string.system_debug_share_redact), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = spacing.xs), - ) - - Spacer(modifier = Modifier.height(spacing.sm)) - - Button( - onClick = { viewModel.runDebugShare() }, - enabled = !state.sharing, - modifier = Modifier.fillMaxWidth(), - ) { - if (state.sharing) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.size(spacing.sm)) - } else { - Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - } - Text( - if (state.sharing) { - stringResource(R.string.system_debug_share_uploading) - } else { - stringResource(R.string.system_debug_share_generate) - }, - maxLines = 1, - softWrap = false, - ) - } - - // Debug share results - state.debugShare?.let { share -> - Spacer(modifier = Modifier.height(spacing.sm)) - @Suppress("DEPRECATION") - val clipboardManager = LocalClipboardManager.current - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - StatusBadge( - text = - if (share.ok == true) { - stringResource(R.string.system_debug_share_uploaded) - } else { - stringResource(R.string.system_debug_share_failed) - }, - status = if (share.ok == true) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, - ) - share.redacted?.let { redacted -> - StatusBadge( - text = - if (redacted) { - stringResource(R.string.system_debug_share_redacted) - } else { - stringResource(R.string.system_debug_share_not_redacted) - }, - status = StatusBadgeType.INFO, - ) - } - share.auto_delete_seconds?.let { secs -> - StatusBadge( - text = stringResource(R.string.system_debug_share_auto_delete, secs / 3600), - status = StatusBadgeType.NEUTRAL, - ) - } - } - - share.urls?.forEach { (name, url) -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "$name: $url", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = { - @Suppress("DEPRECATION") - clipboardManager.setText(AnnotatedString(url)) - }) { - Icon( - Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.system_debug_share_copy_all), - modifier = Modifier.size(16.dp), - ) - } - } - } - } - } - } - } -} - -private fun LazyListScope.checkpointsSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - pruneConfirm: Boolean, - onPruneConfirm: (Boolean) -> Unit, -) { - state.checkpoints?.let { checkpoints -> - item { - SectionHeader(title = stringResource(R.string.system_sec_checkpoints)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - val sessionsCount = checkpoints.sessions?.size ?: 0 - val totalBytes = checkpoints.total_bytes ?: 0L - InfoRow( - label = "", - value = - stringResource( - R.string.system_checkpoints_summary, - sessionsCount, - formatBytes(totalBytes), - ), - ) - - Spacer(modifier = Modifier.height(spacing.sm)) - Button( - onClick = { onPruneConfirm(true) }, - enabled = sessionsCount > 0, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Filled.DeleteForever, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - Text(stringResource(R.string.system_checkpoints_prune), maxLines = 1, softWrap = false) - } - } - } - } - } -} - -private fun LazyListScope.shellHooksSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - viewModel: SystemViewModel, -) { - val hooks = state.hooks?.hooks ?: emptyList() - - item { - SectionHeader( - title = stringResource(R.string.system_sec_hooks), - trailing = { - FilledTonalButton(onClick = { viewModel.toggleHookModal() }) { - Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - Text(stringResource(R.string.system_hooks_new), maxLines = 1, softWrap = false) - } - }, - ) - } - - if (hooks.isEmpty()) { - item { - Text( - text = stringResource(R.string.system_hooks_empty), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = spacing.md, vertical = spacing.sm), - ) - } - } - - items(hooks, key = { "${it.event}:${it.command}" }) { hook -> - HookCard(hook = hook, spacing = spacing, onDelete = { - viewModel.deleteHook(hook.event ?: "", hook.command ?: "") - }) - } - - // Hook creation modal - if (state.hookModalOpen) { - item { - AlertDialog( - onDismissRequest = { viewModel.toggleHookModal() }, - title = { Text(stringResource(R.string.system_hooks_modal_title)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) { - OutlinedTextField( - value = state.hookEvent, - onValueChange = viewModel::updateHookEvent, - label = { Text(stringResource(R.string.system_hooks_field_event)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.hookCommand, - onValueChange = viewModel::updateHookCommand, - label = { Text(stringResource(R.string.system_hooks_field_command)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.hookMatcher, - onValueChange = viewModel::updateHookMatcher, - label = { Text(stringResource(R.string.system_hooks_field_matcher)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.hookTimeout, - onValueChange = viewModel::updateHookTimeout, - label = { Text(stringResource(R.string.system_hooks_field_timeout)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - ) - Row(verticalAlignment = Alignment.CenterVertically) { - androidx.compose.material3.Switch( - checked = state.hookApprove, - onCheckedChange = viewModel::updateHookApprove, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - Text( - text = stringResource(R.string.system_hooks_field_approve), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = stringResource(R.string.system_hooks_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - }, - confirmButton = { - TextButton( - onClick = { viewModel.createHook() }, - enabled = !state.creatingHook && state.hookCommand.isNotBlank(), - ) { - if (state.creatingHook) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.size(spacing.sm)) - } - Text(stringResource(R.string.system_hooks_create)) - } - }, - dismissButton = { - TextButton(onClick = { viewModel.toggleHookModal() }) { - Text(stringResource(R.string.system_confirm_cancel)) - } - }, - ) - } - } -} - -private fun LazyListScope.actionLogSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - viewModel: SystemViewModel, -) { - item { - SectionHeader( - title = stringResource(R.string.system_action_log_title), - trailing = { - TextButton(onClick = { viewModel.closeActionLog() }) { - Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.size(spacing.xs)) - Text(stringResource(R.string.action_cancel)) - } - }, - ) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Action title - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - Text( - text = state.activeAction ?: "", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - ) - val actionLog = state.actionLog - if (actionLog != null) { - if (actionLog.running == true) { - StatusBadge( - text = stringResource(R.string.system_action_log_running), - status = StatusBadgeType.INFO, - ) - } else { - StatusBadge( - text = stringResource(R.string.system_action_log_done), - status = StatusBadgeType.SUCCESS, - ) - actionLog.exit_code?.let { code -> - Spacer(modifier = Modifier.size(spacing.xs)) - StatusBadge( - text = stringResource(R.string.system_action_log_exit, code), - status = - if (code == 0) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, - ) - } - } - } else { - StatusBadge( - text = stringResource(R.string.system_action_log_starting), - status = StatusBadgeType.NEUTRAL, - ) - } - } - - // Output lines - state.actionLog?.lines?.let { lines -> - if (lines.isNotEmpty()) { - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) { - Column(modifier = Modifier.padding(spacing.sm)) { - lines.take(20).forEach { line -> - Text( - text = line, - style = MaterialTheme.typography.bodySmall, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (lines.size > 20) { - Text( - text = stringResource(R.string.system_more_lines, lines.size - 20), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - } - } - } -} - -private val actionButtonPadding = - androidx.compose.foundation.layout - .PaddingValues(horizontal = 8.dp, vertical = 8.dp) - -@Composable -private fun ActionButtonContent( - icon: ImageVector?, - text: String, - iconTint: androidx.compose.ui.graphics.Color = androidx.compose.material3.LocalContentColor.current, - textColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Unspecified, - loading: Boolean = false, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - if (loading) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - } else if (icon != null) { - Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = iconTint) - } - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - color = textColor, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } -} - -/** Staged SAF-picked backup archive for the restore flow (issue #786). */ -private data class PendingImport( - val uri: Uri, - val name: String, - val mime: String, -) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionButtonContent.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionButtonContent.kt new file mode 100644 index 00000000..59cb4a6b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionButtonContent.kt @@ -0,0 +1,55 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +val ActionButtonPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp) + +@Composable +fun ActionButtonContent( + icon: ImageVector?, + text: String, + iconTint: Color = LocalContentColor.current, + textColor: Color = Color.Unspecified, + loading: Boolean = false, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else if (icon != null) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = iconTint) + } + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = textColor, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionLogSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionLogSection.kt new file mode 100644 index 00000000..20a58659 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionLogSection.kt @@ -0,0 +1,135 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel + +fun LazyListScope.actionLogSection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, +) { + item { + SectionHeader( + title = stringResource(R.string.system_action_log_title), + trailing = { + TextButton(onClick = { viewModel.closeActionLog() }) { + Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Action title + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Text( + text = state.activeAction ?: "", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + val actionLog = state.actionLog + if (actionLog != null) { + if (actionLog.running == true) { + StatusBadge( + text = stringResource(R.string.system_action_log_running), + status = StatusBadgeType.INFO, + ) + } else { + StatusBadge( + text = stringResource(R.string.system_action_log_done), + status = StatusBadgeType.SUCCESS, + ) + actionLog.exit_code?.let { code -> + Spacer(modifier = Modifier.size(spacing.xs)) + StatusBadge( + text = stringResource(R.string.system_action_log_exit, code), + status = + if (code == 0) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, + ) + } + } + } else { + StatusBadge( + text = stringResource(R.string.system_action_log_starting), + status = StatusBadgeType.NEUTRAL, + ) + } + } + + // Output lines + state.actionLog?.lines?.let { lines -> + if (lines.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(spacing.sm)) { + lines.take(20).forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (lines.size > 20) { + Text( + text = stringResource(R.string.system_more_lines, lines.size - 20), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/CheckpointsSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CheckpointsSection.kt new file mode 100644 index 00000000..2659c158 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CheckpointsSection.kt @@ -0,0 +1,78 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.InfoRow +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.util.formatBytes + +fun LazyListScope.checkpointsSection( + state: SystemUiState, + spacing: Spacing, + onPruneConfirm: (Boolean) -> Unit, +) { + state.checkpoints?.let { checkpoints -> + item { + SectionHeader(title = stringResource(R.string.system_sec_checkpoints)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + val sessionsCount = checkpoints.sessions?.size ?: 0 + val totalBytes = checkpoints.total_bytes ?: 0L + InfoRow( + label = "", + value = + stringResource( + R.string.system_checkpoints_summary, + sessionsCount, + formatBytes(totalBytes), + ), + ) + + Spacer(modifier = Modifier.height(spacing.sm)) + Button( + onClick = { onPruneConfirm(true) }, + enabled = sessionsCount > 0, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.DeleteForever, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_checkpoints_prune), maxLines = 1, softWrap = false) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialsSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialsSection.kt new file mode 100644 index 00000000..8ab04680 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialsSection.kt @@ -0,0 +1,152 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel + +fun LazyListScope.credentialsSection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, + onRemoveRequest: (Pair) -> Unit, +) { + item { + SectionHeader( + title = stringResource(R.string.system_sec_credentials), + trailing = { + val hasCreds = state.credentials.any { it.entries?.isNotEmpty() == true } + if (hasCreds) { + Text( + text = "${state.credentials.sumOf { it.entries?.size ?: 0 }} key(s)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Add credential form + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + OutlinedTextField( + value = state.credProvider, + onValueChange = viewModel::updateCredProvider, + label = { Text(stringResource(R.string.system_credentials_provider)) }, + singleLine = true, + modifier = Modifier.weight(1f), + textStyle = MaterialTheme.typography.bodySmall, + ) + } + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + OutlinedTextField( + value = state.credKey, + onValueChange = viewModel::updateCredKey, + label = { Text(stringResource(R.string.system_credentials_api_key)) }, + singleLine = true, + modifier = Modifier.weight(1f), + textStyle = MaterialTheme.typography.bodySmall, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + OutlinedTextField( + value = state.credLabel, + onValueChange = viewModel::updateCredLabel, + label = { Text(stringResource(R.string.system_credentials_label)) }, + singleLine = true, + modifier = Modifier.weight(1f), + textStyle = MaterialTheme.typography.bodySmall, + ) + } + Spacer(modifier = Modifier.height(spacing.sm)) + Button( + onClick = { viewModel.addCredential() }, + enabled = !state.addingCred && state.credKey.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + if (state.addingCred) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.size(spacing.sm)) + } else { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + } + Text(stringResource(R.string.system_credentials_add_key), maxLines = 1, softWrap = false) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + + // Provider list with entries + if (state.credentials.isEmpty()) { + Text( + text = stringResource(R.string.system_credentials_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = spacing.sm), + ) + } + + state.credentials.forEach { provider -> + Text( + text = provider.provider ?: "?", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = spacing.xs), + ) + provider.entries?.forEach { entry -> + CredentialEntryRow( + entry = entry, + providerName = provider.provider ?: "", + onRemove = { onRemoveRequest(Pair(provider.provider ?: "", entry.index ?: 0)) }, + spacing = spacing, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/CuratorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CuratorSection.kt new file mode 100644 index 00000000..8ea2fc39 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CuratorSection.kt @@ -0,0 +1,136 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.InfoRow +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel + +fun LazyListScope.curatorSection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, +) { + state.curator?.let { curator -> + item { + SectionHeader(title = stringResource(R.string.system_sec_curator)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Status + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + val statusText: String + val statusType: StatusBadgeType + when { + curator.enabled != true -> { + statusText = stringResource(R.string.system_status_disabled) + statusType = StatusBadgeType.ERROR + } + + curator.paused == true -> { + statusText = stringResource(R.string.system_status_paused) + statusType = StatusBadgeType.WARNING + } + + else -> { + statusText = stringResource(R.string.system_status_active) + statusType = StatusBadgeType.SUCCESS + } + } + StatusBadge(text = statusText, status = statusType) + } + + // Interval info + curator.interval_hours?.let { hrs -> + InfoRow( + label = stringResource(R.string.system_curator_interval, hrs), + value = "", + ) + Spacer(modifier = Modifier.height(spacing.xs)) + } + + // Last run + curator.last_run_at?.let { lastRun -> + InfoRow( + label = stringResource(R.string.system_curator_last_run, lastRun), + value = "", + ) + } ?: InfoRow( + label = stringResource(R.string.system_curator_never_run), + value = "", + ) + + // Actions + if (curator.enabled == true) { + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + OutlinedButton( + onClick = { viewModel.toggleCuratorPaused() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + if (curator.paused == true) { + ActionButtonContent( + icon = Icons.Filled.PlayArrow, + text = stringResource(R.string.system_curator_resume), + ) + } else { + ActionButtonContent( + icon = Icons.Filled.Pause, + text = stringResource(R.string.system_curator_pause), + ) + } + } + Button( + onClick = { viewModel.runCuratorNow() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.Refresh, + text = stringResource(R.string.system_curator_run_now), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt new file mode 100644 index 00000000..35262e2d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt @@ -0,0 +1,187 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.InfoRow +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel + +fun LazyListScope.gatewaySection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, +) { + state.status?.let { status -> + item { + SectionHeader(title = stringResource(R.string.system_sec_gateway)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Status badge + version + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + val isRunning = status.gateway_running == true + StatusBadge( + text = + if (isRunning) { + stringResource(R.string.system_status_running) + } else { + stringResource(R.string.system_status_stopped) + }, + status = if (isRunning) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, + ) + } + } + + // Version · Sessions · PID + status.version?.let { ver -> + val parts = mutableListOf(ver) + status.active_sessions?.let { parts.add("$it session(s)") } + state.doctorReport?.let { report -> + if (report.ok && report.pid != null) { + parts.add("pid ${report.pid}") + } + } + InfoRow( + label = stringResource(R.string.system_label_hermes_version), + value = parts.joinToString(" · "), + ) + } + + // Auth required + status.auth_required?.let { auth -> + InfoRow( + label = stringResource(R.string.system_auth_required), + value = if (auth) "yes" else "no", + ) + } + + // Active platforms + status.gateway_platforms?.let { platforms -> + if (platforms.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + platforms.forEach { (name, platform) -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Icon( + Icons.Filled.Devices, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + platform?.state?.let { platformState -> + StatusBadge( + text = platformState, + status = + when (platformState.lowercase()) { + "connected", "running", "ready" -> StatusBadgeType.SUCCESS + "error", "disconnected", "failed" -> StatusBadgeType.ERROR + else -> StatusBadgeType.NEUTRAL + }, + ) + } + } + } + } + } + + // Action buttons + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + val isRunning = status.gateway_running == true + if (!isRunning) { + Button( + onClick = { viewModel.startGateway() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.PlayArrow, + text = stringResource(R.string.system_gateway_start), + ) + } + } + if (isRunning) { + Button( + onClick = { viewModel.restartGateway() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.RestartAlt, + text = stringResource(R.string.system_gateway_restart), + ) + } + OutlinedButton( + onClick = { viewModel.stopGateway() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.Stop, + text = stringResource(R.string.system_gateway_stop), + iconTint = MaterialTheme.colorScheme.error, + textColor = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCreateDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCreateDialog.kt new file mode 100644 index 00000000..5a55d978 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCreateDialog.kt @@ -0,0 +1,121 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.LocalSpacing + +@Composable +fun HookCreateDialog( + isOpen: Boolean, + event: String, + command: String, + matcher: String, + timeout: String, + approve: Boolean, + isCreating: Boolean, + onEventChange: (String) -> Unit, + onCommandChange: (String) -> Unit, + onMatcherChange: (String) -> Unit, + onTimeoutChange: (String) -> Unit, + onApproveChange: (Boolean) -> Unit, + onCreate: () -> Unit, + onDismiss: () -> Unit, +) { + if (!isOpen) return + val spacing = LocalSpacing.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.system_hooks_modal_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) { + OutlinedTextField( + value = event, + onValueChange = onEventChange, + label = { Text(stringResource(R.string.system_hooks_field_event)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = command, + onValueChange = onCommandChange, + label = { Text(stringResource(R.string.system_hooks_field_command)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = matcher, + onValueChange = onMatcherChange, + label = { Text(stringResource(R.string.system_hooks_field_matcher)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = timeout, + onValueChange = onTimeoutChange, + label = { Text(stringResource(R.string.system_hooks_field_timeout)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = approve, + onCheckedChange = onApproveChange, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + Text( + text = stringResource(R.string.system_hooks_field_approve), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = stringResource(R.string.system_hooks_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + }, + confirmButton = { + TextButton( + onClick = onCreate, + enabled = !isCreating && command.isNotBlank(), + ) { + if (isCreating) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.size(spacing.sm)) + } + Text(stringResource(R.string.system_hooks_create)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.system_confirm_cancel)) + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/HostSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HostSection.kt new file mode 100644 index 00000000..6a85336b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HostSection.kt @@ -0,0 +1,234 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Update +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.InfoRow +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatCard +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel +import com.m57.hermescontrol.util.formatBytes +import com.m57.hermescontrol.util.formatDuration + +@OptIn(ExperimentalLayoutApi::class) +fun LazyListScope.hostSection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, +) { + state.stats?.let { stats -> + item { + SectionHeader(title = stringResource(R.string.system_sec_host)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // OS, Arch, Hostname + stats.os?.let { InfoRow(stringResource(R.string.system_label_os), it) } + stats.arch?.let { InfoRow(stringResource(R.string.system_label_arch), it) } + stats.hostname?.let { InfoRow(stringResource(R.string.system_label_hostname), it) } + stats.python_version?.let { python -> + val impl = stats.python_impl?.let { " ($it)" } ?: "" + InfoRow(stringResource(R.string.system_label_python), "$python$impl") + } + + // Hermes version + update badge + stats.hermes_version?.let { ver -> + val updateBadge = + state.updateInfo?.let { info -> + when { + info.update_available == true && info.behind != null && info.behind > 0 -> { + " (${stringResource( + R.string.system_version_update_available, + )}: ${stringResource(R.string.system_version_behind, info.behind)})" + } + + info.update_available == true -> { + " (${stringResource( + R.string.system_version_update_available, + )})" + } + + else -> { + " (${stringResource(R.string.system_version_latest)})" + } + } + } ?: "" + InfoRow(stringResource(R.string.system_label_hermes_version), "$ver$updateBadge") + } + + // Update buttons + Row( + modifier = Modifier.fillMaxWidth().padding(top = spacing.sm), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + OutlinedButton( + onClick = { viewModel.checkForUpdate(false) }, + enabled = !state.checkingUpdate, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + if (state.checkingUpdate) { + ActionButtonContent( + icon = null, + text = stringResource(R.string.system_action_check_updates), + loading = true, + ) + } else { + ActionButtonContent( + icon = Icons.Filled.Update, + text = stringResource(R.string.system_action_check_updates), + ) + } + } + state.updateInfo?.let { info -> + if (info.update_available == true && info.can_apply == true) { + Button( + onClick = { viewModel.openUpdateConfirm() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.PlayArrow, + text = stringResource(R.string.system_action_update_now), + ) + } + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + + // CPU / Memory / Disk stat cards + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + stats.cpu_percent?.let { cpu -> + StatCard( + label = stringResource(R.string.system_label_cpu), + value = "${cpu.toInt()}%", + icon = Icons.Filled.Speed, + modifier = Modifier.weight(1f), + ) + } + stats.memory?.percent?.let { mem -> + StatCard( + label = stringResource(R.string.system_label_memory), + value = "${mem.toInt()}%", + icon = Icons.Filled.Memory, + modifier = Modifier.weight(1f), + ) + } + stats.disk?.percent?.let { disk -> + StatCard( + label = stringResource(R.string.system_label_disk), + value = "${disk.toInt()}%", + icon = Icons.Filled.Storage, + modifier = Modifier.weight(1f), + ) + } + } + + // Memory details + stats.memory?.let { mem -> + InfoRow( + label = stringResource(R.string.system_label_memory), + value = + stringResource( + R.string.system_label_used_total_percent, + formatBytes(mem.used ?: 0L), + formatBytes(mem.total ?: 0L), + "${(mem.percent ?: 0.0).toInt()}", + ), + ) + } + + // Disk details + stats.disk?.let { disk -> + InfoRow( + label = stringResource(R.string.system_label_disk), + value = + stringResource( + R.string.system_label_used_total_percent, + formatBytes(disk.used ?: 0L), + formatBytes(disk.total ?: 0L), + "${(disk.percent ?: 0.0).toInt()}", + ), + ) + } + + // Uptime + stats.uptime_seconds?.let { secs -> + InfoRow( + label = stringResource(R.string.system_label_uptime), + value = formatDuration(secs), + ) + } + + // Load avg + stats.load_avg?.let { loads -> + if (loads.isNotEmpty()) { + val coresText = + stats.cpu_count?.let { + " ($it ${stringResource( + R.string.system_label_cores, + )})" + } ?: "" + InfoRow( + label = stringResource(R.string.system_label_load_avg), + value = loads.joinToString(", ") { "%.2f".format(it) } + coresText, + ) + } + } ?: stats.cpu_count?.let { cores -> + InfoRow( + label = stringResource(R.string.system_label_cpu), + value = "$cores ${stringResource(R.string.system_label_cores)}", + ) + } + + // psutil warning + if (stats.psutil == false) { + Spacer(modifier = Modifier.height(spacing.sm)) + StatusBadge( + text = stringResource(R.string.system_psutil_warning), + status = StatusBadgeType.WARNING, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/OperationsSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/OperationsSection.kt new file mode 100644 index 00000000..892e8082 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/OperationsSection.kt @@ -0,0 +1,392 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Backup +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.HealthAndSafety +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel + +fun LazyListScope.operationsSection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, + onImportConfirm: (Boolean) -> Unit, + onImportPick: () -> Unit, + downloadBackup: () -> Unit, +) { + item { + SectionHeader(title = stringResource(R.string.system_sec_operations)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Operation buttons row 1 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + FilledTonalButton( + onClick = { viewModel.loadAll() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.HealthAndSafety, + text = stringResource(R.string.system_op_doctor), + ) + } + FilledTonalButton( + onClick = { viewModel.runSecurityAudit() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.VerifiedUser, + text = stringResource(R.string.system_op_security_audit), + ) + } + FilledTonalButton( + onClick = { viewModel.runUpdateSkills() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.Refresh, + text = stringResource(R.string.system_op_update_skills), + ) + } + } + + Spacer(modifier = Modifier.height(spacing.sm)) + + // Operation buttons row 2 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + FilledTonalButton( + onClick = { viewModel.runPromptSize() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.HealthAndSafety, + text = stringResource(R.string.system_op_prompt_size), + ) + } + FilledTonalButton( + onClick = { viewModel.runDump() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.Build, + text = stringResource(R.string.system_op_dump), + ) + } + FilledTonalButton( + onClick = { viewModel.runConfigMigrate() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.Build, + text = stringResource(R.string.system_op_config_migrate), + ) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + + // Backup section + Text( + text = stringResource(R.string.system_sec_backup), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Button( + onClick = { viewModel.triggerBackup() }, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + ActionButtonContent( + icon = Icons.Filled.Backup, + text = stringResource(R.string.system_op_backup_create), + ) + } + OutlinedButton( + onClick = downloadBackup, + enabled = state.backupArchive != null && !state.isDownloading, + modifier = Modifier.weight(1f), + contentPadding = ActionButtonPadding, + ) { + if (state.isDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.size(spacing.xs)) + } else { + Icon( + Icons.Filled.CloudDownload, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(spacing.xs)) + } + Text( + text = stringResource(R.string.system_op_backup_download), + maxLines = 1, + softWrap = false, + ) + } + } + + // Restore from picked archive (issue #786: SAF picker → upload) + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = stringResource(R.string.system_sec_restore), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + + OutlinedButton( + onClick = onImportPick, + enabled = !state.isImporting, + modifier = Modifier.fillMaxWidth(), + contentPadding = ActionButtonPadding, + ) { + Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text( + text = stringResource(R.string.system_op_restore_pick), + maxLines = 1, + softWrap = false, + ) + } + + state.importFileName?.let { name -> + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + Icon( + imageVector = Icons.Filled.Description, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(modifier = Modifier.height(spacing.sm)) + + Button( + onClick = { onImportConfirm(true) }, + enabled = state.importFileName != null && !state.isImporting, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isImporting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onError, + ) + Spacer(modifier = Modifier.size(spacing.xs)) + } else { + Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + } + Text(stringResource(R.string.system_op_restore_upload), maxLines = 1, softWrap = false) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + + // Debug share section + SectionHeader(title = stringResource(R.string.system_sec_debug_share)) + Spacer(modifier = Modifier.height(spacing.sm)) + + Text( + text = stringResource(R.string.system_debug_share_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + + Switch( + checked = state.shareRedact, + onCheckedChange = { viewModel.toggleShareRedact() }, + ) + Spacer(modifier = Modifier.size(spacing.sm)) + Text( + text = stringResource(R.string.system_debug_share_redact), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = spacing.xs), + ) + + Spacer(modifier = Modifier.height(spacing.sm)) + + Button( + onClick = { viewModel.runDebugShare() }, + enabled = !state.sharing, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.sharing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.size(spacing.sm)) + } else { + Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + } + Text( + if (state.sharing) { + stringResource(R.string.system_debug_share_uploading) + } else { + stringResource(R.string.system_debug_share_generate) + }, + maxLines = 1, + softWrap = false, + ) + } + + // Debug share results + state.debugShare?.let { share -> + Spacer(modifier = Modifier.height(spacing.sm)) + @Suppress("DEPRECATION") + val clipboardManager = LocalClipboardManager.current + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + StatusBadge( + text = + if (share.ok == true) { + stringResource(R.string.system_debug_share_uploaded) + } else { + stringResource(R.string.system_debug_share_failed) + }, + status = if (share.ok == true) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR, + ) + share.redacted?.let { redacted -> + StatusBadge( + text = + if (redacted) { + stringResource(R.string.system_debug_share_redacted) + } else { + stringResource(R.string.system_debug_share_not_redacted) + }, + status = StatusBadgeType.INFO, + ) + } + share.auto_delete_seconds?.let { secs -> + StatusBadge( + text = stringResource(R.string.system_debug_share_auto_delete, secs / 3600), + status = StatusBadgeType.NEUTRAL, + ) + } + } + + share.urls?.forEach { (name, url) -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "$name: $url", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { + @Suppress("DEPRECATION") + clipboardManager.setText(AnnotatedString(url)) + }) { + Icon( + Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.system_debug_share_copy_all), + modifier = Modifier.size(16.dp), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/PortalSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/PortalSection.kt new file mode 100644 index 00000000..37dd7720 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/PortalSection.kt @@ -0,0 +1,115 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Link +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.InfoRow +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.system.SystemUiState + +fun LazyListScope.portalSection( + state: SystemUiState, + spacing: Spacing, + uriHandler: UriHandler, +) { + state.portal?.let { portal -> + item { + SectionHeader(title = stringResource(R.string.system_sec_portal)) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + // Login status + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + val loggedIn = portal.logged_in == true + StatusBadge( + text = + if (loggedIn) { + stringResource(R.string.system_status_logged_in) + } else { + stringResource(R.string.system_status_not_logged_in) + }, + status = if (loggedIn) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING, + ) + if (!loggedIn) { + Text( + text = stringResource(R.string.system_portal_login_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Provider + portal.provider?.let { provider -> + InfoRow( + label = stringResource(R.string.system_portal_provider, provider), + value = "", + ) + } + + // Manage subscription link + portal.subscription_url?.let { url -> + TextButton( + onClick = { uriHandler.openUri(url) }, + modifier = Modifier.padding(top = spacing.xs), + ) { + Icon(Icons.Filled.Link, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text( + stringResource(R.string.system_portal_manage_subscription), + maxLines = 1, + softWrap = false, + ) + } + } + + // Feature routing + portal.features?.let { features -> + if (features.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + features.forEach { feature -> + InfoRow( + label = feature.label ?: stringResource(R.string.system_portal_feature_routing), + value = feature.state ?: "", + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/ShellHooksSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ShellHooksSection.kt new file mode 100644 index 00000000..66c9db3e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ShellHooksSection.kt @@ -0,0 +1,60 @@ +package com.m57.hermescontrol.ui.system.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.system.SystemUiState +import com.m57.hermescontrol.ui.system.SystemViewModel + +fun LazyListScope.shellHooksSection( + state: SystemUiState, + spacing: Spacing, + viewModel: SystemViewModel, +) { + val hooks = state.hooks?.hooks ?: emptyList() + + item { + SectionHeader( + title = stringResource(R.string.system_sec_hooks), + trailing = { + FilledTonalButton(onClick = { viewModel.toggleHookModal() }) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.system_hooks_new), maxLines = 1, softWrap = false) + } + }, + ) + } + + if (hooks.isEmpty()) { + item { + Text( + text = stringResource(R.string.system_hooks_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = spacing.md, vertical = spacing.sm), + ) + } + } + + items(hooks, key = { "${it.event}:${it.command}" }) { hook -> + HookCard(hook = hook, spacing = spacing, onDelete = { + viewModel.deleteHook(hook.event ?: "", hook.command ?: "") + }) + } +} From 3d711c5a5184f07224a7d15032e154864461b991 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 19:28:52 +0300 Subject: [PATCH 23/42] refactor(sessions): decompose SessionsScreen into focused components Extract SessionCard, SearchResultCard, SessionActionMenu, SessionsStatsRow, SessionsBulkActionBar, SessionsDialogs, and AutomationGroupList. --- .../ui/sessions/SessionsScreen.kt | 680 +----------------- .../components/AutomationGroupList.kt | 90 +++ .../sessions/components/SearchResultCard.kt | 198 +++++ .../sessions/components/SessionActionMenu.kt | 109 +++ .../ui/sessions/components/SessionCard.kt | 285 ++++++++ .../components/SessionsBulkActionBar.kt | 127 ++++ .../ui/sessions/components/SessionsDialogs.kt | 152 ++++ .../sessions/components/SessionsStatsRow.kt | 98 +++ 8 files changed, 1098 insertions(+), 641 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/AutomationGroupList.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionActionMenu.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsBulkActionBar.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsDialogs.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsStatsRow.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index bd912149..0de2a4cb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -116,6 +116,12 @@ import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listItemSpacing +import com.m57.hermescontrol.ui.sessions.components.SearchResultCard +import com.m57.hermescontrol.ui.sessions.components.SessionCard +import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar +import com.m57.hermescontrol.ui.sessions.components.SessionsDialogs +import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow +import com.m57.hermescontrol.ui.sessions.components.automationGroups /** * Auto-load the next history page when the user scrolls to within this many @@ -680,65 +686,14 @@ fun SessionsScreen( else -> { Column(modifier = Modifier.fillMaxSize()) { // ── Stats row ─────────────────────────────────────── - Row( - modifier = - Modifier - .fillMaxWidth() - .height(IntrinsicSize.Max) - .padding(horizontal = spacing.md, vertical = spacing.sm), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - StatCard( - label = stringResource(R.string.sessions_stat_total), - value = formatCompactCount(state.total), - icon = Icons.Filled.History, - modifier = Modifier.weight(1f).fillMaxHeight(), - ) - StatCard( - label = stringResource(R.string.sessions_stat_messages_loaded), - value = formatCompactCount(loadedMessageCount), - icon = Icons.Filled.Email, - modifier = Modifier.weight(1f).fillMaxHeight(), - ) - // Prune button — compact card sized to its label, so the - // stat cards keep enough width for large counts. - if (state.section == HistorySection.CONVERSATIONS) { - Card( - modifier = Modifier.fillMaxHeight(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - onClick = { viewModel.showPruneDialog() }, - ) { - Box( - // NOTE: fillMaxHeight only — never fillMaxSize here. - // An unweighted Row child with fillMaxSize expands to the - // full row width and starves the weighted stat cards. - modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - Icon( - imageVector = Icons.Filled.DeleteSweep, - contentDescription = null, - tint = statusColors.warning, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource(R.string.sessions_action_prune), - style = MaterialTheme.typography.labelMedium, - color = statusColors.warning, - fontWeight = FontWeight.SemiBold, - ) - } - } - } - } - } + SessionsStatsRow( + total = state.total, + loadedMessageCount = loadedMessageCount, + section = state.section, + spacing = spacing, + statusColors = statusColors, + onPruneClick = { viewModel.showPruneDialog() }, + ) // ── Session list ──────────────────────────────────── val listState = rememberLazyListState() @@ -822,66 +777,21 @@ fun SessionsScreen( verticalArrangement = listItemSpacing, ) { if (state.section == HistorySection.AUTOMATIONS) { - automationSessionGroups.forEach { group -> - item(key = "automation-group:${group.key}") { - val expanded = group.key in expandedAutomationGroups - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { - expandedAutomationGroups = - if (expanded) { - expandedAutomationGroups - group.key - } else { - expandedAutomationGroups + group.key - } - }.padding(horizontal = spacing.sm, vertical = spacing.md), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = - if (expanded) { - Icons.Filled.KeyboardArrowDown - } else { - Icons.AutoMirrored.Filled.KeyboardArrowRight - }, - contentDescription = null, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - Column { - Text( - text = - group.title - ?: group.jobId?.let { - stringResource( - R.string.sessions_section_automation_job, - it, - ) - } ?: stringResource( - R.string.sessions_section_automation_unknown, - ), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = - stringResource( - R.string.sessions_section_automation_runs, - group.sessions.size, - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + automationGroups( + groups = automationSessionGroups, + expandedGroups = expandedAutomationGroups, + sessionItemsById = sessionItemsById, + spacing = spacing, + onToggleGroup = { groupKey -> + expandedAutomationGroups = + if (groupKey in expandedAutomationGroups) { + expandedAutomationGroups - groupKey + } else { + expandedAutomationGroups + groupKey } - } - } - if (group.key in expandedAutomationGroups) { - items(group.sessions, key = { it.id }) { session -> - sessionItemsById[session.id]?.let { sessionCard(it) } - } - } - } + }, + renderSessionCard = sessionCard, + ) } else { items(sessionsToDisplay, key = { it.session.id }) { item -> sessionCard(item) @@ -927,533 +837,21 @@ fun SessionsScreen( } // ── Bulk action toolbar (animated) ────────────────────────────────── - AnimatedVisibility( + SessionsBulkActionBar( visible = state.isSelecting && hasSelection, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), - modifier = Modifier.fillMaxSize(), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - shadowElevation = 8.dp, - tonalElevation = 4.dp, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = spacing.md, vertical = spacing.sm), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - // Select all / deselect - OutlinedButton( - onClick = { - if (allVisibleSessionsSelected) { - viewModel.clearSelection() - } else { - viewModel.selectAll(visibleSessionIds) - } - }, - ) { - Icon( - imageVector = - if (allVisibleSessionsSelected) { - Icons.Filled.Close - } else { - Icons.Filled.SelectAll - }, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - if (allVisibleSessionsSelected) { - stringResource(R.string.sessions_action_deselect_all) - } else { - stringResource(R.string.sessions_action_select_all) - }, - ) - } - - // Delete selected - Button( - onClick = { viewModel.requestBulkDelete() }, - enabled = !state.isDeletingBulk, - colors = - ButtonDefaults.buttonColors( - containerColor = statusColors.error, - ), - ) { - if (state.isDeletingBulk) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = statusColors.onError, - ) - } else { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - stringResource( - R.string.sessions_action_delete_n, - state.selectedIds.size, - ), - ) - } - } - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun SessionCard( - session: com.m57.hermescontrol.data.model.SessionInfo, - displayTitle: String, - branchStem: String?, - isFork: Boolean = false, - forkDepth: Int = 0, - query: String, - isSelecting: Boolean, - isSelected: Boolean, - isDeleting: Boolean, - isPinned: Boolean, - isHidden: Boolean = false, - highlightBackground: Color, - highlightForeground: Color, - onCardClick: () -> Unit, - onToggleSelection: () -> Unit, - onSelect: () -> Unit, - onRename: () -> Unit, - onTogglePin: () -> Unit, - onToggleHide: () -> Unit, - onDelete: () -> Unit, -) { - val spacing = LocalSpacing.current - val statusColors = LocalHermesStatusColors.current - val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming" - val srcIcon = sourceIcon(session.source) - var menuExpanded by remember { mutableStateOf(false) } - - Card( - modifier = - Modifier - .fillMaxWidth() - .testTag("session_card_${session.id}") - .combinedClickable( - onClick = onCardClick, - // Long-press opens the per-session action menu (issue #785): - // Select / Rename / Delete. The old long-press behavior - // (jump straight into selection mode) is now the menu's - // "Select" item. - onLongClick = { if (!isSelecting) menuExpanded = true }, - ), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - border = - if (isActive && !isSelecting) { - BorderStroke(2.dp, statusColors.success) - } else if (isSelected) { - BorderStroke(2.dp, MaterialTheme.colorScheme.primary) - } else { - null - }, - ) { - Box { - Row( - modifier = Modifier.padding(spacing.md), - verticalAlignment = Alignment.CenterVertically, - ) { - // Checkbox in select mode - if (isSelecting) { - Checkbox( - checked = isSelected, - onCheckedChange = { onToggleSelection() }, - modifier = Modifier.testTag("session_checkbox_${session.id}"), - ) - Spacer(modifier = Modifier.width(spacing.sm)) - } - - // Fork marker: a fork icon + its nesting depth, so a chain reads - // 🍴1 → 🍴2 → 🍴3 and a branch-off pops out. No indentation — the - // number carries the structure so names never get squished. - if (isFork && !isSelecting) { - Icon( - imageVector = Icons.AutoMirrored.Filled.CallSplit, - contentDescription = stringResource(R.string.sessions_fork_indicator), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp), - ) - if (forkDepth > 0) { - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - text = forkDepth.toString(), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - } - Spacer(modifier = Modifier.width(spacing.sm)) - } - - // Source icon - if (srcIcon != null && !isSelecting) { - Icon( - imageVector = srcIcon, - contentDescription = sourceLabel(session.source), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(spacing.sm)) - } - - // Main content - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = - if (query.isNotBlank()) { - highlightText(displayTitle, query, highlightBackground, highlightForeground) - } else { - AnnotatedString(displayTitle) - }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - if (isPinned) { - Spacer(modifier = Modifier.width(spacing.xs)) - Icon( - imageVector = Icons.Filled.PushPin, - contentDescription = stringResource(R.string.sessions_pinned_indicator), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(14.dp), - ) - } - } - - Spacer(modifier = Modifier.height(spacing.xs)) - - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.history_message_count, session.message_count ?: 0), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (session.hidden == true) { - Spacer(modifier = Modifier.width(spacing.sm)) - StatusBadge( - text = stringResource(R.string.sessions_hidden_badge), - status = StatusBadgeType.NEUTRAL, - ) - } - if (!session.status.isNullOrBlank()) { - Spacer(modifier = Modifier.width(spacing.sm)) - StatusBadge( - text = session.status, - status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL, - ) - } - } - } - } - - // Per-session action menu (hold the card to open) - SessionActionMenu( - expanded = menuExpanded, - onDismiss = { menuExpanded = false }, - isDeleting = isDeleting, - isPinned = isPinned, - onTogglePin = { - menuExpanded = false - onTogglePin() - }, - isHidden = isHidden, - onToggleHide = { - menuExpanded = false - onToggleHide() - }, - onSelect = { - menuExpanded = false - onSelect() - }, - onRename = { - menuExpanded = false - onRename() - }, - onDelete = { - menuExpanded = false - onDelete() - }, - ) - } - } -} - -/** - * Search-result card. The backend search payload has no session title, so this card is - * honest about it: it shows a "Match" label + the highlighted snippet as the body, plus - * source / model / played-at metadata chips. It never presents the snippet as a name. - */ -@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) -@Composable -private fun SearchResultCard( - session: com.m57.hermescontrol.data.model.SessionInfo, - query: String, - isSelecting: Boolean, - isSelected: Boolean, - isDeleting: Boolean, - highlightBackground: Color, - highlightForeground: Color, - onCardClick: () -> Unit, - onToggleSelection: () -> Unit, - onSelect: () -> Unit, - onRename: () -> Unit, - onDelete: () -> Unit, -) { - val spacing = LocalSpacing.current - val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) - val cleanSnippet = cleanSearchSnippet(snippet) - val playedAt = formatPlayedAt(session.started_at) - var menuExpanded by remember { mutableStateOf(false) } - - Card( - modifier = - Modifier - .fillMaxWidth() - .testTag("session_card_${session.id}") - .combinedClickable( - onClick = onCardClick, - // Long-press opens the per-session action menu (issue #785) - onLongClick = { if (!isSelecting) menuExpanded = true }, - ), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - border = - if (isSelected) { - BorderStroke(2.dp, MaterialTheme.colorScheme.primary) - } else { - null - }, - ) { - Box { - Row( - modifier = Modifier.padding(spacing.sm), - verticalAlignment = Alignment.Top, - ) { - // Checkbox in select mode - if (isSelecting) { - Checkbox( - checked = isSelected, - onCheckedChange = { onToggleSelection() }, - modifier = Modifier.testTag("session_checkbox_${session.id}"), - ) - Spacer(modifier = Modifier.width(spacing.sm)) - } - - Column(modifier = Modifier.weight(1f)) { - // Header row: "Match" label + source icon - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - val srcIcon = sourceIcon(session.source) - if (srcIcon != null && !isSelecting) { - Icon( - imageVector = srcIcon, - contentDescription = sourceLabel(session.source), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp), - ) - } - Text( - text = stringResource(R.string.sessions_search_match_label), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - letterSpacing = 0.5.sp, - ) - if (!session.id.isNullOrBlank()) { - Text( - text = "· ${session.id.take(8)}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Spacer(modifier = Modifier.height(spacing.xs)) - - // The matched snippet, highlighted — shown as the body, NOT as a title. - Text( - text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - ) - - Spacer(modifier = Modifier.height(spacing.xs)) - - // Metadata chips row - FlowRow( - horizontalArrangement = Arrangement.spacedBy(spacing.xs), - verticalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - session.source?.let { src -> - StatusBadge( - text = sourceLabel(src), - status = StatusBadgeType.NEUTRAL, - ) - } - session.model?.let { mdl -> - StatusBadge( - text = stringResource(R.string.sessions_search_model_label) + ": " + mdl, - status = StatusBadgeType.INFO, - ) - } - playedAt?.let { time -> - StatusBadge( - text = stringResource(R.string.sessions_search_played_at, time), - status = StatusBadgeType.NEUTRAL, - ) - } - } - } - } - - // Per-session action menu (hold the card to open) - SessionActionMenu( - expanded = menuExpanded, - onDismiss = { menuExpanded = false }, - isDeleting = isDeleting, - onSelect = { - menuExpanded = false - onSelect() - }, - onRename = { - menuExpanded = false - onRename() - }, - onDelete = { - menuExpanded = false - onDelete() - }, - ) - } - } -} - -/** - * Per-session hold menu (issue #785): Pin/Unpin / Select / Rename / Delete. - * Opened by long-pressing a session card. The pin item is only shown when - * [onTogglePin] is provided (history cards; search-result cards omit it). - */ -@Composable -private fun SessionActionMenu( - expanded: Boolean, - onDismiss: () -> Unit, - isDeleting: Boolean, - isPinned: Boolean = false, - onTogglePin: (() -> Unit)? = null, - isHidden: Boolean = false, - onToggleHide: (() -> Unit)? = null, - onSelect: () -> Unit, - onRename: () -> Unit, - onDelete: () -> Unit, -) { - val statusColors = LocalHermesStatusColors.current - DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { - if (onTogglePin != null) { - DropdownMenuItem( - text = { - Text( - stringResource( - if (isPinned) { - R.string.sessions_action_unpin - } else { - R.string.sessions_action_pin - }, - ), - ) - }, - leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) }, - onClick = onTogglePin, - ) - } - if (onToggleHide != null) { - DropdownMenuItem( - text = { - Text( - stringResource( - if (isHidden) { - R.string.sessions_action_unhide - } else { - R.string.sessions_action_hide - }, - ), - ) - }, - leadingIcon = { - Icon( - imageVector = - if (isHidden) { - Icons.Filled.Visibility - } else { - Icons.Filled.VisibilityOff - }, - contentDescription = null, - ) - }, - onClick = onToggleHide, - ) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.sessions_action_select)) }, - leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) }, - onClick = onSelect, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.sessions_action_rename)) }, - leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) }, - onClick = onRename, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.action_delete)) }, - leadingIcon = { - if (isDeleting) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + allVisibleSessionsSelected = allVisibleSessionsSelected, + selectedCount = state.selectedIds.size, + isDeletingBulk = state.isDeletingBulk, + spacing = spacing, + statusColors = statusColors, + onSelectAllToggle = { + if (allVisibleSessionsSelected) { + viewModel.clearSelection() } else { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = null, - tint = statusColors.error, - ) + viewModel.selectAll(visibleSessionIds) } }, - enabled = !isDeleting, - onClick = onDelete, + onDeleteSelected = { viewModel.requestBulkDelete() }, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/AutomationGroupList.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/AutomationGroupList.kt new file mode 100644 index 00000000..bc49534e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/AutomationGroupList.kt @@ -0,0 +1,90 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SessionTreeItem +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.sessions.AutomationSessionGroup + +internal fun LazyListScope.automationGroups( + groups: List, + expandedGroups: Set, + sessionItemsById: Map, + spacing: Spacing, + onToggleGroup: (String) -> Unit, + renderSessionCard: @Composable (SessionTreeItem) -> Unit, +) { + groups.forEach { group -> + item(key = "automation-group:${group.key}") { + val expanded = group.key in expandedGroups + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onToggleGroup(group.key) } + .padding(horizontal = spacing.sm, vertical = spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = + if (expanded) { + Icons.Filled.KeyboardArrowDown + } else { + Icons.AutoMirrored.Filled.KeyboardArrowRight + }, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + Column { + Text( + text = + group.title + ?: group.jobId?.let { + stringResource( + R.string.sessions_section_automation_job, + it, + ) + } ?: stringResource( + R.string.sessions_section_automation_unknown, + ), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = + stringResource( + R.string.sessions_section_automation_runs, + group.sessions.size, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (group.key in expandedGroups) { + items(group.sessions, key = { it.id }) { session -> + sessionItemsById[session.id]?.let { renderSessionCard(it) } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt new file mode 100644 index 00000000..054703d4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt @@ -0,0 +1,198 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.sessions.cleanSearchSnippet +import com.m57.hermescontrol.ui.sessions.formatPlayedAt + +/** + * Search-result card. The backend search payload has no session title, so this card is + * honest about it: it shows a "Match" label + the highlighted snippet as the body, plus + * source / model / played-at metadata chips. It never presents the snippet as a name. + */ +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +@Composable +fun SearchResultCard( + session: SessionInfo, + query: String, + isSelecting: Boolean, + isSelected: Boolean, + isDeleting: Boolean, + highlightBackground: Color, + highlightForeground: Color, + onCardClick: () -> Unit, + onToggleSelection: () -> Unit, + onSelect: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + val spacing = LocalSpacing.current + val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) + val cleanSnippet = cleanSearchSnippet(snippet) + val playedAt = formatPlayedAt(session.started_at) + var menuExpanded by remember { mutableStateOf(false) } + + Card( + modifier = + Modifier + .fillMaxWidth() + .testTag("session_card_${session.id}") + .combinedClickable( + onClick = onCardClick, + onLongClick = { if (!isSelecting) menuExpanded = true }, + ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + border = + if (isSelected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Box { + Row( + modifier = Modifier.padding(spacing.sm), + verticalAlignment = Alignment.Top, + ) { + if (isSelecting) { + Checkbox( + checked = isSelected, + onCheckedChange = { onToggleSelection() }, + modifier = Modifier.testTag("session_checkbox_${session.id}"), + ) + Spacer(modifier = Modifier.width(spacing.sm)) + } + + Column(modifier = Modifier.weight(1f)) { + // Header row: "Match" label + source icon + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + val srcIcon = sourceIcon(session.source) + if (srcIcon != null && !isSelecting) { + Icon( + imageVector = srcIcon, + contentDescription = sourceLabel(session.source), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = stringResource(R.string.sessions_search_match_label), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 0.5.sp, + ) + if (!session.id.isNullOrBlank()) { + Text( + text = "· ${session.id.take(8)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(spacing.xs)) + + // The matched snippet, highlighted — shown as the body, NOT as a title. + Text( + text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(spacing.xs)) + + // Metadata chips row + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spacing.xs), + verticalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + session.source?.let { src -> + StatusBadge( + text = sourceLabel(src), + status = StatusBadgeType.NEUTRAL, + ) + } + session.model?.let { mdl -> + StatusBadge( + text = stringResource(R.string.sessions_search_model_label) + ": " + mdl, + status = StatusBadgeType.INFO, + ) + } + playedAt?.let { time -> + StatusBadge( + text = stringResource(R.string.sessions_search_played_at, time), + status = StatusBadgeType.NEUTRAL, + ) + } + } + } + } + + // Per-session action menu (hold the card to open) + SessionActionMenu( + expanded = menuExpanded, + onDismiss = { menuExpanded = false }, + isDeleting = isDeleting, + onSelect = { + menuExpanded = false + onSelect() + }, + onRename = { + menuExpanded = false + onRename() + }, + onDelete = { + menuExpanded = false + onDelete() + }, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionActionMenu.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionActionMenu.kt new file mode 100644 index 00000000..23e0d37b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionActionMenu.kt @@ -0,0 +1,109 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +/** + * Per-session overflow/long-press menu. + */ +@Composable +fun SessionActionMenu( + expanded: Boolean, + onDismiss: () -> Unit, + isDeleting: Boolean, + isPinned: Boolean = false, + onTogglePin: (() -> Unit)? = null, + isHidden: Boolean = false, + onToggleHide: (() -> Unit)? = null, + onSelect: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + val statusColors = LocalHermesStatusColors.current + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + if (onTogglePin != null) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (isPinned) { + R.string.sessions_action_unpin + } else { + R.string.sessions_action_pin + }, + ), + ) + }, + leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) }, + onClick = onTogglePin, + ) + } + if (onToggleHide != null) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (isHidden) { + R.string.sessions_action_unhide + } else { + R.string.sessions_action_hide + }, + ), + ) + }, + leadingIcon = { + Icon( + imageVector = + if (isHidden) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = null, + ) + }, + onClick = onToggleHide, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.sessions_action_select)) }, + leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) }, + onClick = onSelect, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.sessions_action_rename)) }, + leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) }, + onClick = onRename, + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.action_delete), + color = statusColors.error, + ) + }, + leadingIcon = { + Icon( + Icons.Filled.Delete, + contentDescription = null, + tint = statusColors.error, + ) + }, + enabled = !isDeleting, + onClick = onDelete, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt new file mode 100644 index 00000000..9b574913 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt @@ -0,0 +1,285 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.CallSplit +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType + +fun sourceIcon(source: String?): ImageVector? = + when (source?.lowercase()) { + "telegram", "tg" -> Icons.AutoMirrored.Filled.Send + "web", "dashboard" -> Icons.Filled.Language + "api", "rest" -> Icons.Filled.Code + "cli", "terminal" -> Icons.Filled.Terminal + else -> null + } + +fun sourceLabel(source: String?): String = + when (source?.lowercase()) { + "telegram", "tg" -> "Telegram" + "web", "dashboard" -> "Web" + "api", "rest" -> "API" + "cli", "terminal" -> "CLI" + else -> source ?: "Unknown" + } + +fun highlightText( + text: String, + query: String, + highlightBackground: Color, + highlightForeground: Color, +): AnnotatedString = + buildAnnotatedString { + if (query.isBlank()) { + append(text) + return@buildAnnotatedString + } + val lowerText = text.lowercase() + val lowerQuery = query.lowercase() + var currentIndex = 0 + while (currentIndex < text.length) { + val matchIndex = lowerText.indexOf(lowerQuery, currentIndex) + if (matchIndex == -1) { + append(text.substring(currentIndex)) + break + } + if (matchIndex > currentIndex) { + append(text.substring(currentIndex, matchIndex)) + } + withStyle( + SpanStyle( + background = highlightBackground, + color = highlightForeground, + fontWeight = FontWeight.Bold, + ), + ) { + append(text.substring(matchIndex, matchIndex + query.length)) + } + currentIndex = matchIndex + query.length + } + } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SessionCard( + session: SessionInfo, + displayTitle: String, + branchStem: String?, + isFork: Boolean = false, + forkDepth: Int = 0, + query: String, + isSelecting: Boolean, + isSelected: Boolean, + isDeleting: Boolean, + isPinned: Boolean, + isHidden: Boolean = false, + highlightBackground: Color, + highlightForeground: Color, + onCardClick: () -> Unit, + onToggleSelection: () -> Unit, + onSelect: () -> Unit, + onRename: () -> Unit, + onTogglePin: () -> Unit, + onToggleHide: () -> Unit, + onDelete: () -> Unit, +) { + val spacing = LocalSpacing.current + val statusColors = LocalHermesStatusColors.current + val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming" + val srcIcon = sourceIcon(session.source) + var menuExpanded by remember { mutableStateOf(false) } + + Card( + modifier = + Modifier + .fillMaxWidth() + .testTag("session_card_${session.id}") + .combinedClickable( + onClick = onCardClick, + onLongClick = { if (!isSelecting) menuExpanded = true }, + ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + border = + if (isActive && !isSelecting) { + BorderStroke(2.dp, statusColors.success) + } else if (isSelected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Box { + Row( + modifier = Modifier.padding(spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSelecting) { + Checkbox( + checked = isSelected, + onCheckedChange = { onToggleSelection() }, + modifier = Modifier.testTag("session_checkbox_${session.id}"), + ) + Spacer(modifier = Modifier.width(spacing.sm)) + } + + if (isFork && !isSelecting) { + Icon( + imageVector = Icons.AutoMirrored.Filled.CallSplit, + contentDescription = stringResource(R.string.sessions_fork_indicator), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + if (forkDepth > 0) { + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + text = forkDepth.toString(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + Spacer(modifier = Modifier.width(spacing.sm)) + } + + if (srcIcon != null && !isSelecting) { + Icon( + imageVector = srcIcon, + contentDescription = sourceLabel(session.source), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(spacing.sm)) + } + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = + if (query.isNotBlank()) { + highlightText(displayTitle, query, highlightBackground, highlightForeground) + } else { + AnnotatedString(displayTitle) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (isPinned) { + Spacer(modifier = Modifier.width(spacing.xs)) + Icon( + imageVector = Icons.Filled.PushPin, + contentDescription = stringResource(R.string.sessions_pinned_indicator), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp), + ) + } + } + + Spacer(modifier = Modifier.height(spacing.xs)) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.history_message_count, session.message_count ?: 0), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (session.hidden == true) { + Spacer(modifier = Modifier.width(spacing.sm)) + StatusBadge( + text = stringResource(R.string.sessions_hidden_badge), + status = StatusBadgeType.NEUTRAL, + ) + } + if (!session.status.isNullOrBlank()) { + Spacer(modifier = Modifier.width(spacing.sm)) + StatusBadge( + text = session.status, + status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL, + ) + } + } + } + } + + SessionActionMenu( + expanded = menuExpanded, + onDismiss = { menuExpanded = false }, + isDeleting = isDeleting, + isPinned = isPinned, + onTogglePin = { + menuExpanded = false + onTogglePin() + }, + isHidden = isHidden, + onToggleHide = { + menuExpanded = false + onToggleHide() + }, + onSelect = { + menuExpanded = false + onSelect() + }, + onRename = { + menuExpanded = false + onRename() + }, + onDelete = { + menuExpanded = false + onDelete() + }, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsBulkActionBar.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsBulkActionBar.kt new file mode 100644 index 00000000..f97bb25b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsBulkActionBar.kt @@ -0,0 +1,127 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.Spacing + +@Composable +fun SessionsBulkActionBar( + visible: Boolean, + allVisibleSessionsSelected: Boolean, + selectedCount: Int, + isDeletingBulk: Boolean, + spacing: Spacing, + statusColors: HermesStatusColors, + onSelectAllToggle: () -> Unit, + onDeleteSelected: () -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shadowElevation = 8.dp, + tonalElevation = 4.dp, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = spacing.md, vertical = spacing.sm), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onSelectAllToggle) { + Icon( + imageVector = + if (allVisibleSessionsSelected) { + Icons.Filled.Close + } else { + Icons.Filled.SelectAll + }, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + if (allVisibleSessionsSelected) { + stringResource(R.string.sessions_action_deselect_all) + } else { + stringResource(R.string.sessions_action_select_all) + }, + ) + } + + Button( + onClick = onDeleteSelected, + enabled = !isDeletingBulk, + colors = + ButtonDefaults.buttonColors( + containerColor = statusColors.error, + ), + ) { + if (isDeletingBulk) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = statusColors.onError, + ) + } else { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + stringResource( + R.string.sessions_action_delete_n, + selectedCount, + ), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsDialogs.kt new file mode 100644 index 00000000..58cd4bc3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsDialogs.kt @@ -0,0 +1,152 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.ConfirmDialog +import com.m57.hermescontrol.ui.sessions.SessionsUiState +import com.m57.hermescontrol.ui.sessions.cleanSearchSnippet + +@Composable +fun SessionsDialogs( + state: SessionsUiState, + pruneDays: String, + onPruneDaysChange: (String) -> Unit, + statusColors: HermesStatusColors, + onConfirmEmptyCleanup: () -> Unit, + onDismissEmptyCleanup: () -> Unit, + onConfirmPrune: (Int) -> Unit, + onDismissPrune: () -> Unit, + onConfirmDeleteSession: () -> Unit, + onDismissDeleteSession: () -> Unit, + onConfirmBulkDelete: () -> Unit, + onDismissBulkDelete: () -> Unit, + onRenameSession: (String, String) -> Unit, + onRenameDraftChange: (String) -> Unit, + onDismissRename: () -> Unit, +) { + val spacing = LocalSpacing.current + + // Empty-session cleanup dialog + if (state.showEmptyCleanupDialog) { + ConfirmDialog( + title = stringResource(R.string.sessions_empty_cleanup_title), + message = stringResource(R.string.sessions_empty_cleanup_message, state.emptyCount), + onConfirm = onConfirmEmptyCleanup, + onDismiss = onDismissEmptyCleanup, + confirmText = stringResource(R.string.sessions_empty_cleanup_confirm), + ) + } + + // Prune dialog + if (state.showPruneDialog) { + ConfirmDialog( + title = stringResource(R.string.sessions_prune_title), + message = stringResource(R.string.sessions_prune_desc), + onConfirm = { + val days = pruneDays.toIntOrNull() + if (days != null && days > 0) onConfirmPrune(days) + }, + onDismiss = onDismissPrune, + confirmText = stringResource(R.string.sessions_prune_confirm), + isDestructive = true, + extraContent = { + Spacer(modifier = Modifier.height(spacing.sm)) + OutlinedTextField( + value = pruneDays, + onValueChange = onPruneDaysChange, + label = { Text(stringResource(R.string.sessions_prune_days_label)) }, + placeholder = { Text("7") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + val days = pruneDays.toIntOrNull() + if (days != null && days > 0) onConfirmPrune(days) + }, + ), + ) + }, + ) + } + + // Single-session delete confirmation dialog + if (state.sessionToDeleteConfirm != null) { + val sessionToDelete = state.sessionToDeleteConfirm + val sessionTitle = + state.sessions + .find { it.id == sessionToDelete } + ?.title + ?.takeIf { it.isNotBlank() } + ?: state.searchResults + .find { it.session_id == sessionToDelete } + ?.snippet + ?.let(::cleanSearchSnippet) + ?.take(80) + ?: stringResource(R.string.history_untitled) + + ConfirmDialog( + title = stringResource(R.string.sessions_delete_title), + message = stringResource(R.string.sessions_delete_message, sessionTitle), + onConfirm = onConfirmDeleteSession, + onDismiss = onDismissDeleteSession, + confirmText = stringResource(R.string.action_delete), + isDestructive = true, + ) + } + + // Bulk delete confirmation dialog + if (state.showBulkDeleteConfirm) { + ConfirmDialog( + title = stringResource(R.string.sessions_bulk_delete_title), + message = stringResource(R.string.sessions_bulk_delete_message, state.selectedIds.size), + onConfirm = onConfirmBulkDelete, + onDismiss = onDismissBulkDelete, + confirmText = stringResource(R.string.action_delete), + isDestructive = true, + ) + } + + // Single-session rename dialog + state.renamingSessionId?.let { sessionId -> + AlertDialog( + onDismissRequest = onDismissRename, + title = { Text(stringResource(R.string.sessions_rename_title)) }, + text = { + OutlinedTextField( + value = state.renameDraft, + onValueChange = onRenameDraftChange, + singleLine = true, + label = { Text(stringResource(R.string.sessions_rename_hint)) }, + ) + }, + confirmButton = { + TextButton( + onClick = { onRenameSession(sessionId, state.renameDraft) }, + enabled = state.renameDraft.isNotBlank(), + ) { + Text(stringResource(R.string.sessions_action_rename)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRename) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsStatsRow.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsStatsRow.kt new file mode 100644 index 00000000..4558dfd2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionsStatsRow.kt @@ -0,0 +1,98 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.History +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.StatCard +import com.m57.hermescontrol.ui.sessions.HistorySection +import com.m57.hermescontrol.ui.sessions.formatCompactCount + +@Composable +fun SessionsStatsRow( + total: Int, + loadedMessageCount: Int, + section: HistorySection, + spacing: Spacing, + statusColors: HermesStatusColors, + onPruneClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Max) + .padding(horizontal = spacing.md, vertical = spacing.sm), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + StatCard( + label = stringResource(R.string.sessions_stat_total), + value = formatCompactCount(total), + icon = Icons.Filled.History, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + StatCard( + label = stringResource(R.string.sessions_stat_messages_loaded), + value = formatCompactCount(loadedMessageCount), + icon = Icons.Filled.Email, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + if (section == HistorySection.CONVERSATIONS) { + Card( + modifier = Modifier.fillMaxHeight(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + onClick = onPruneClick, + ) { + Box( + modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + Icon( + imageVector = Icons.Filled.DeleteSweep, + contentDescription = null, + tint = statusColors.warning, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.sessions_action_prune), + style = MaterialTheme.typography.labelMedium, + color = statusColors.warning, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + } +} From 1edd5449c9ca8ce7c958731c199aaa1c4934d339 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 19:38:29 +0300 Subject: [PATCH 24/42] refactor(chat): modularize update section and media launchers Extract ChatAppUpdateSection and rememberChatMediaLaunchers to components to decouple STT and update dialogs from ChatScreen. --- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 309 ++---------------- .../chat/components/ChatAppUpdateSection.kt | 103 ++++++ .../ui/chat/components/ChatMediaLaunchers.kt | 257 +++++++++++++++ 3 files changed, 384 insertions(+), 285 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatAppUpdateSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index b31d5774..3223cc33 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -1,13 +1,8 @@ package com.m57.hermescontrol.ui.chat -import android.Manifest import android.app.Activity -import android.content.ActivityNotFoundException import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri -import android.provider.OpenableColumns -import android.speech.RecognizerIntent import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -68,10 +63,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.core.content.FileProvider import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle @@ -82,13 +74,11 @@ import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource -import com.m57.hermescontrol.data.update.AppUpdateCache -import com.m57.hermescontrol.data.update.AppUpdateState -import com.m57.hermescontrol.data.update.UpdateNoticeManager import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner import com.m57.hermescontrol.ui.chat.components.ChatInputBar import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects @@ -104,26 +94,21 @@ import com.m57.hermescontrol.ui.chat.components.SearchBarRow import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet import com.m57.hermescontrol.ui.chat.components.TaskProgressChip +import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList import com.m57.hermescontrol.ui.common.ActionProgressDialog -import com.m57.hermescontrol.ui.common.AppUpdateDialog import com.m57.hermescontrol.ui.common.AutoScrollingTitleText import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.model.components.ModelPickerDialog -import com.m57.hermescontrol.ui.settings.AppUpdateViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale private const val SESSION_SYNC_INTERVAL_MS = 30_000L @@ -304,7 +289,6 @@ fun ChatScreen( viewModel.consumeComposerTextRestore() } } - var isListening by rememberSaveable { mutableStateOf(false) } var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } var showReloginDialog by rememberSaveable { mutableStateOf(false) } var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) } @@ -322,137 +306,24 @@ fun ChatScreen( ) } - val micListeningPrompt = stringResource(R.string.chat_mic_listening) - val sttNotAvailableMsg = stringResource(R.string.stt_not_available) - val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied) - - // Speech-to-text recognition launcher (issue #194) - val speechLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult(), - ) { result -> - ExternalActivityLifecycleGuard.externalActivityReturned() - isListening = false - if (result.resultCode == Activity.RESULT_OK) { - val spokenText = - result.data - ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) - ?.firstOrNull() - .orEmpty() - if (spokenText.isNotBlank()) { - val merged = - if (inputFieldValue.text.isBlank()) { - spokenText - } else { - "${inputFieldValue.text} $spokenText" - } - inputFieldValue = ChatInputPolicy.commandFieldValue(merged) - } - } - } - - // Mic permission launcher - val micPermissionLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { granted -> - ExternalActivityLifecycleGuard.externalActivityReturned() - if (granted) { - if (SpeechInputHelper.isSpeechInputAvailable(context)) { - val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt) - isListening = true - launchExternalActivity { - try { - speechLauncher.launch(intent) - } catch (_: ActivityNotFoundException) { - isListening = false - scrollScope.launch { - snackbarHostState.showSnackbar(sttNotAvailableMsg) - } - } - } - } else { - scrollScope.launch { - snackbarHostState.showSnackbar(sttNotAvailableMsg) - } - } - } else { + val mediaLaunchers = + rememberChatMediaLaunchers( + inputFieldValue = inputFieldValue, + onInputFieldValueChange = { inputFieldValue = it }, + onAddAttachment = { uri, name, mimeType, size -> + viewModel.addAttachment(uri, name, mimeType, size) + }, + onAddAttachments = { attachments -> + viewModel.addAttachments(attachments) + }, + onShowMessage = { msg -> scrollScope.launch { - snackbarHostState.showSnackbar(sttPermissionDeniedMsg) + snackbarHostState.showSnackbar(msg) } - } - } - - // Multi-file picker for attachments (issue #195). - val filePickerLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.GetMultipleContents(), - ) { uris: List -> - ExternalActivityLifecycleGuard.externalActivityReturned() - val attachments = - uris.mapNotNull { uri -> - runCatching { - var name = uri.lastPathSegment ?: "file" - var size = 0L - context.contentResolver - .query( - uri, - arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), - null, - null, - null, - )?.use { cursor -> - if (cursor.moveToFirst()) { - val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) - if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx) - if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx) - } - } - Attachment( - uri = uri.toString(), - name = name, - mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream", - size = size, - ) - }.onFailure { error -> - Log.w("ChatScreen", "Skipping unreadable picked attachment", error) - }.getOrNull() - } - viewModel.addAttachments(attachments) - } - - // Camera photo launcher (issue #195) - var pendingCameraUri by remember { mutableStateOf(null) } - val cameraErrorMsg = stringResource(R.string.chat_camera_error) - val cameraLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.TakePicture(), - ) { success -> - ExternalActivityLifecycleGuard.externalActivityReturned() - val uri = pendingCameraUri - pendingCameraUri = null - if (success && uri != null) { - try { - val fileName = - "photo_${ - SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format( - Date(), - ) - }.jpg" - val inputStream = context.contentResolver.openInputStream(uri) - val size = inputStream?.use { it.available().toLong() } ?: 0L - viewModel.addAttachment(uri.toString(), fileName, "image/jpeg", size) - } catch (e: Exception) { - Log.e("ChatScreen", "Camera capture failed", e) - scrollScope.launch { - snackbarHostState.showSnackbar( - cameraErrorMsg, - ) - } - } - } - } + }, + launchExternalActivity = launchExternalActivity, + context = context, + ) // Lifecycle effects, permissions, session switching, auto-scroll, errors ChatLifecycleEffects( @@ -592,80 +463,7 @@ fun ChatScreen( // Issue #890: launch update check — non-blocking banner when a // newer release exists. Tapping "Update" opens the in-place dialog. - val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle() - val appUpdateViewModel: AppUpdateViewModel = - viewModel { - val app = - this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] - ?: error("Application not available") - AppUpdateViewModel(app) - } - val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle() - - val updateLifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - androidx.compose.runtime.DisposableEffect(updateLifecycleOwner) { - val observer = - androidx.lifecycle.LifecycleEventObserver { _, event -> - if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { - if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) { - appUpdateViewModel.resumeInstallAfterPermission() - } - } - } - updateLifecycleOwner.lifecycle.addObserver(observer) - onDispose { - updateLifecycleOwner.lifecycle.removeObserver(observer) - } - } - - if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) { - val noticeTag = - (updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag - ?: UpdateNoticeManager.noticeTag() - if (noticeTag != null) { - com.m57.hermescontrol.ui.common.UpdateNoticeBanner( - latestTag = noticeTag, - onUpdate = { AppUpdateCache.showDialog() }, - onDismiss = { AppUpdateCache.dismiss() }, - ) - } - } - - if (AppUpdateCache.isDialogVisible) { - val context = androidx.compose.ui.platform.LocalContext.current - val dialogState = - if (appUpdateState !is AppUpdateState.Idle) { - appUpdateState - } else { - updateNotice - } - AppUpdateDialog( - state = dialogState, - onDismiss = { AppUpdateCache.hideDialog() }, - onStartUpdate = { appUpdateViewModel.startUpdate() }, - onCancelDownload = { appUpdateViewModel.cancelDownload() }, - onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() }, - onOpenSettings = { - val intent = - android.content - .Intent( - android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, - android.net.Uri.parse("package:${context.packageName}"), - ).apply { - addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - } - try { - context.startActivity(intent) - } catch (e: Exception) { - context.startActivity( - android.content.Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS).apply { - addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - }, - ) - } - }, - ) - } + ChatAppUpdateSection() AnimatedVisibility( visible = searchState.isActive, @@ -808,75 +606,16 @@ fun ChatScreen( // Jump to bottom after send (serialized through the controller). scrollController.jumpToBottom(animated = true) }, - onMicTap = { - if (isListening) { - isListening = false - } else if ( - ContextCompat.checkSelfPermission( - context, - Manifest.permission.RECORD_AUDIO, - ) == PackageManager.PERMISSION_GRANTED - ) { - if (SpeechInputHelper.isSpeechInputAvailable(context)) { - val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt) - isListening = true - launchExternalActivity { - try { - speechLauncher.launch(intent) - } catch (_: ActivityNotFoundException) { - isListening = false - scrollScope.launch { - snackbarHostState.showSnackbar(sttNotAvailableMsg) - } - } - } - } else { - scrollScope.launch { - snackbarHostState.showSnackbar(sttNotAvailableMsg) - } - } - } else { - launchExternalActivity { - micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - } - }, - isListening = isListening, + onMicTap = mediaLaunchers.onMicTap, + isListening = mediaLaunchers.isListening, isAgentTyping = state.isAgentTyping, isConnected = state.isConnected, commandCatalog = state.commandCatalog, slashUsageCounts = state.slashUsageCounts, pendingAttachments = state.pendingAttachments, - onCameraTap = { - try { - val timeStamp = - SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val photoFile = - File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir) - val uri = - FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - photoFile, - ) - pendingCameraUri = uri - launchExternalActivity { - cameraLauncher.launch(uri) - } - } catch (e: Exception) { - Log.e("ChatScreen", "Camera launch failed", e) - } - }, - onImageTap = { - launchExternalActivity { - filePickerLauncher.launch("image/*") - } - }, - onFileTap = { - launchExternalActivity { - filePickerLauncher.launch("*/*") - } - }, + onCameraTap = mediaLaunchers.onCameraTap, + onImageTap = mediaLaunchers.onImageTap, + onFileTap = mediaLaunchers.onFileTap, onRemoveAttachment = viewModel::removeAttachment, onPreviewAttachment = { attachment -> if (attachment.isImage) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatAppUpdateSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatAppUpdateSection.kt new file mode 100644 index 00000000..2ec2e26b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatAppUpdateSection.kt @@ -0,0 +1,103 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.data.update.AppUpdateCache +import com.m57.hermescontrol.data.update.AppUpdateState +import com.m57.hermescontrol.data.update.UpdateNoticeManager +import com.m57.hermescontrol.ui.common.AppUpdateDialog +import com.m57.hermescontrol.ui.common.UpdateNoticeBanner +import com.m57.hermescontrol.ui.settings.AppUpdateViewModel + +@Composable +fun ChatAppUpdateSection() { + val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle() + val appUpdateViewModel: AppUpdateViewModel = + viewModel { + val app = + this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] + ?: error("Application not available") + AppUpdateViewModel(app) + } + val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle() + + val updateLifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(updateLifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) { + appUpdateViewModel.resumeInstallAfterPermission() + } + } + } + updateLifecycleOwner.lifecycle.addObserver(observer) + onDispose { + updateLifecycleOwner.lifecycle.removeObserver(observer) + } + } + + if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) { + val noticeTag = + (updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag + ?: UpdateNoticeManager.noticeTag() + if (noticeTag != null) { + UpdateNoticeBanner( + latestTag = noticeTag, + onUpdate = { AppUpdateCache.showDialog() }, + onDismiss = { AppUpdateCache.dismiss() }, + ) + } + } + + if (AppUpdateCache.isDialogVisible) { + val context = LocalContext.current + val dialogState = + if (appUpdateState !is AppUpdateState.Idle) { + appUpdateState + } else { + updateNotice + } + AppUpdateDialog( + state = dialogState, + onDismiss = { AppUpdateCache.hideDialog() }, + onStartUpdate = { appUpdateViewModel.startUpdate() }, + onCancelDownload = { appUpdateViewModel.cancelDownload() }, + onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() }, + onOpenSettings = { + launchUnknownAppSourcesSettings(context) + }, + ) + } +} + +private fun launchUnknownAppSourcesSettings(context: Context) { + val intent = + Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}"), + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + } catch (_: Exception) { + context.startActivity( + Intent(Settings.ACTION_SECURITY_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt new file mode 100644 index 00000000..afb68002 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt @@ -0,0 +1,257 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.Manifest +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.OpenableColumns +import android.speech.RecognizerIntent +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import com.m57.hermescontrol.ExternalActivityLifecycleGuard +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.ui.chat.ChatInputPolicy +import com.m57.hermescontrol.ui.chat.SpeechInputHelper +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class ChatMediaLaunchers( + val isListening: Boolean, + val onMicTap: () -> Unit, + val onCameraTap: () -> Unit, + val onImageTap: () -> Unit, + val onFileTap: () -> Unit, +) + +@Composable +fun rememberChatMediaLaunchers( + inputFieldValue: TextFieldValue, + onInputFieldValueChange: (TextFieldValue) -> Unit, + onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit, + onAddAttachments: (List) -> Unit, + onShowMessage: (String) -> Unit, + launchExternalActivity: (() -> Unit) -> Unit, + context: Context = LocalContext.current, +): ChatMediaLaunchers { + var isListening by remember { mutableStateOf(false) } + var pendingCameraUri by remember { mutableStateOf(null) } + + val currentInputFieldValue by rememberUpdatedState(inputFieldValue) + val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange) + val currentOnAddAttachment by rememberUpdatedState(onAddAttachment) + val currentOnAddAttachments by rememberUpdatedState(onAddAttachments) + val currentOnShowMessage by rememberUpdatedState(onShowMessage) + val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity) + + val micListeningPrompt = stringResource(R.string.chat_mic_listening) + val sttNotAvailableMsg = stringResource(R.string.stt_not_available) + val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied) + val cameraErrorMsg = stringResource(R.string.chat_camera_error) + + // Speech-to-text recognition launcher (issue #194) + val speechLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + ExternalActivityLifecycleGuard.externalActivityReturned() + isListening = false + if (result.resultCode == Activity.RESULT_OK) { + val spokenText = + result.data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + ?.firstOrNull() + .orEmpty() + if (spokenText.isNotBlank()) { + val currentText = currentInputFieldValue.text + val merged = + if (currentText.isBlank()) { + spokenText + } else { + "$currentText $spokenText" + } + currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged)) + } + } + } + + // Mic permission launcher + val micPermissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + ExternalActivityLifecycleGuard.externalActivityReturned() + if (granted) { + if (SpeechInputHelper.isSpeechInputAvailable(context)) { + val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt) + isListening = true + currentLaunchExternalActivity { + try { + speechLauncher.launch(intent) + } catch (_: ActivityNotFoundException) { + isListening = false + currentOnShowMessage(sttNotAvailableMsg) + } + } + } else { + currentOnShowMessage(sttNotAvailableMsg) + } + } else { + currentOnShowMessage(sttPermissionDeniedMsg) + } + } + + // Multi-file picker for attachments (issue #195) + val filePickerLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.GetMultipleContents(), + ) { uris: List -> + ExternalActivityLifecycleGuard.externalActivityReturned() + val attachments = + uris.mapNotNull { uri -> + runCatching { + var name = uri.lastPathSegment ?: "file" + var size = 0L + context.contentResolver + .query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) + if (nameIdx >= 0 && !cursor.isNull(nameIdx)) name = cursor.getString(nameIdx) + if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx) + } + } + Attachment( + uri = uri.toString(), + name = name, + mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream", + size = size, + ) + }.onFailure { error -> + Log.w("ChatScreen", "Skipping unreadable picked attachment", error) + }.getOrNull() + } + currentOnAddAttachments(attachments) + } + + // Camera photo launcher (issue #195) + val cameraLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.TakePicture(), + ) { success -> + ExternalActivityLifecycleGuard.externalActivityReturned() + val uri = pendingCameraUri + pendingCameraUri = null + if (success && uri != null) { + try { + val fileName = + "photo_${ + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format( + Date(), + ) + }.jpg" + val inputStream = context.contentResolver.openInputStream(uri) + val size = inputStream?.use { it.available().toLong() } ?: 0L + currentOnAddAttachment(uri.toString(), fileName, "image/jpeg", size) + } catch (e: Exception) { + Log.e("ChatScreen", "Camera capture failed", e) + currentOnShowMessage(cameraErrorMsg) + } + } + } + + val onMicTap: () -> Unit = { + if (isListening) { + isListening = false + } else if ( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + ) { + if (SpeechInputHelper.isSpeechInputAvailable(context)) { + val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt) + isListening = true + currentLaunchExternalActivity { + try { + speechLauncher.launch(intent) + } catch (_: ActivityNotFoundException) { + isListening = false + currentOnShowMessage(sttNotAvailableMsg) + } + } + } else { + currentOnShowMessage(sttNotAvailableMsg) + } + } else { + currentLaunchExternalActivity { + micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + + val onCameraTap: () -> Unit = { + try { + val timeStamp = + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val photoFile = + File.createTempFile("camera_${timeStamp}_", ".jpg", context.cacheDir) + val uri = + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + photoFile, + ) + pendingCameraUri = uri + currentLaunchExternalActivity { + cameraLauncher.launch(uri) + } + } catch (e: Exception) { + Log.e("ChatScreen", "Camera launch failed", e) + } + } + + val onImageTap: () -> Unit = { + currentLaunchExternalActivity { + filePickerLauncher.launch("image/*") + } + } + + val onFileTap: () -> Unit = { + currentLaunchExternalActivity { + filePickerLauncher.launch("*/*") + } + } + + return remember(isListening) { + ChatMediaLaunchers( + isListening = isListening, + onMicTap = onMicTap, + onCameraTap = onCameraTap, + onImageTap = onImageTap, + onFileTap = onFileTap, + ) + } +} From d2e5e8e1aaf2a37cf8fe6564217f496e97dacfb3 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 19:47:26 +0300 Subject: [PATCH 25/42] refactor(group): decompose GroupChatScreen into components Extract GroupChatComposer, GroupChatSettingsDialog, GroupMessageCard, and GroupChatToolChip into components package. --- .../ui/bots/group/GroupChatScreen.kt | 948 +----------------- .../group/components/GroupChatComposer.kt | 296 ++++++ .../components/GroupChatSettingsDialog.kt | 333 ++++++ .../group/components/GroupChatToolChip.kt | 210 ++++ .../bots/group/components/GroupMessageCard.kt | 313 ++++++ 5 files changed, 1167 insertions(+), 933 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatComposer.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatToolChip.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index 4835b2a9..dc1febd0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -95,6 +95,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalChatFontScale +import com.m57.hermescontrol.ui.bots.group.components.GroupChatComposer +import com.m57.hermescontrol.ui.bots.group.components.GroupChatSettingsDialog +import com.m57.hermescontrol.ui.bots.group.components.GroupMessageCard import com.m57.hermescontrol.ui.chat.ChatInputPolicy import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab @@ -324,238 +327,23 @@ fun GroupChatScreen( } } - // ── MENTION SUGGESTION CHIP BAR (Horizontal scrollable chips) ── - val mentionQuery = - remember(inputFieldValue.text, inputFieldValue.selection.end) { - ChatInputPolicy.extractMentionQuery( - inputFieldValue.text, - inputFieldValue.selection.end, - ) - } - + // ── COMPOSER ── val groupBots = remember(state.members) { state.members.distinctBy { it.name.lowercase() } } - val showAllOption = - remember(mentionQuery, groupBots) { - if (mentionQuery == null || groupBots.isEmpty()) { - false - } else { - "all".startsWith(mentionQuery, ignoreCase = true) || - "everyone".startsWith(mentionQuery, ignoreCase = true) - } - } - - val filteredBots = - remember(mentionQuery, groupBots) { - if (mentionQuery == null) { - emptyList() - } else { - groupBots.filter { bot -> - bot.name.startsWith(mentionQuery, ignoreCase = true) || - bot.effectiveTitle.contains(mentionQuery, ignoreCase = true) - } - } - } - - AnimatedVisibility( - visible = mentionQuery != null && (showAllOption || filteredBots.isNotEmpty()), - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - LazyRow( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 2.dp) - .testTag("group_chat_mention_row"), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (showAllOption) { - item(key = "mention_all") { - SuggestionChip( - onClick = { - inputFieldValue = ChatInputPolicy.applyMention(inputFieldValue, "all") - }, - label = { - Text( - text = "@all", - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - }, - icon = { - Icon( - imageVector = Icons.Filled.Groups, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - }, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.testTag("mention_chip_all"), - ) - } - } - - items(filteredBots, key = { it.name }) { bot -> - SuggestionChip( - onClick = { - inputFieldValue = ChatInputPolicy.applyMention(inputFieldValue, bot.name) - }, - label = { - Text( - text = "@${bot.name}", - fontWeight = FontWeight.SemiBold, - style = MaterialTheme.typography.labelMedium, - ) - }, - icon = { - BotAvatar( - name = bot.name, - avatar = bot.botMeta()?.avatar, - size = 18.dp, - showPresence = false, - ) - }, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.testTag("mention_chip_${bot.name}"), - ) - } - } - } - - // ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ── - val ambientLayoutDirection = LocalLayoutDirection.current - val inputLayoutDirection = - remember(inputFieldValue.text, ambientLayoutDirection) { - BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection) - } - val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl - - CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { - BasicTextField( - value = inputFieldValue, - onValueChange = { inputFieldValue = it }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp) - .onFocusChanged { isFocused = it.isFocused } - .testTag("group_chat_input"), - textStyle = - MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, - textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, - ), - singleLine = false, - maxLines = 4, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { handleSend() }), - decorationBox = { innerTextField -> - CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { - Surface( - shape = RoundedCornerShape(20.dp), - border = - BorderStroke( - width = if (isFocused) 2.dp else 1.dp, - color = - if (isFocused) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - }, - ), - color = MaterialTheme.colorScheme.surface, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.weight(1f), - contentAlignment = - if (isInputRtl) { - Alignment.CenterEnd - } else { - Alignment.CenterStart - }, - ) { - CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { - if (inputFieldValue.text.isEmpty()) { - Text( - text = - stringResource( - R.string.group_chat_composer_hint, - state.groupName.ifBlank { groupName }, - ), - style = MaterialTheme.typography.bodyMedium, - color = - MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = 0.6f, - ), - textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, - modifier = Modifier.fillMaxWidth(), - ) - } - innerTextField() - } - } - - Spacer(modifier = Modifier.width(4.dp)) - - if (state.activeSpeaker != null) { - IconButton( - onClick = { viewModel.stopGeneration() }, - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - modifier = - Modifier - .size(36.dp) - .testTag("group_chat_composer_stop_button"), - ) { - Icon( - imageVector = Icons.Filled.Stop, - contentDescription = stringResource(R.string.group_chat_action_stop), - modifier = Modifier.size(18.dp), - ) - } - } else { - IconButton( - onClick = handleSend, - enabled = inputFieldValue.text.isNotBlank(), - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = - Modifier - .size(36.dp) - .testTag("group_chat_send_button"), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = "Send", - modifier = Modifier.size(18.dp), - ) - } - } - } - } - } - }, - ) - } + GroupChatComposer( + inputFieldValue = inputFieldValue, + onInputValueChange = { inputFieldValue = it }, + groupBots = groupBots, + groupName = state.groupName.ifBlank { groupName }, + activeSpeaker = state.activeSpeaker, + isFocused = isFocused, + onFocusChanged = { isFocused = it }, + onSend = handleSend, + onStop = { viewModel.stopGeneration() }, + ) } } @@ -572,709 +360,3 @@ fun GroupChatScreen( ) } } - -@Composable -private fun GroupChatSettingsDialog( - currentMaxMessages: Int, - currentMaxPasses: Int, - currentSystemPrompt: String?, - onDismiss: () -> Unit, - onSave: (Int, Int, String?) -> Unit, -) { - var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) } - var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) } - var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) } - var showHighLimitsConfirmation by remember { mutableStateOf(false) } - - val isHighLimit = - maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES || - maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(R.string.group_chat_settings_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - }, - text = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Max Bot Messages Slider - Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.group_chat_settings_max_messages), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = "${maxMessages.roundToInt()}", - style = MaterialTheme.typography.labelLarge, - color = - if (maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - }, - fontWeight = FontWeight.Bold, - ) - } - Text( - text = - stringResource( - R.string.group_chat_settings_max_messages_desc, - maxMessages.roundToInt(), - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Slider( - value = maxMessages, - onValueChange = { maxMessages = it }, - valueRange = 1f..MAX_ALLOWED_BOT_MESSAGES.toFloat(), - steps = MAX_ALLOWED_BOT_MESSAGES - 2, - modifier = Modifier.testTag("max_messages_slider"), - ) - } - - // Max Continuation Handoffs Slider - Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.group_chat_settings_max_handoffs), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = "${maxPasses.roundToInt()}", - style = MaterialTheme.typography.labelLarge, - color = - if (maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - }, - fontWeight = FontWeight.Bold, - ) - } - Text( - text = - stringResource( - R.string.group_chat_settings_max_handoffs_desc, - maxPasses.roundToInt(), - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Slider( - value = maxPasses, - onValueChange = { maxPasses = it }, - valueRange = 0f..MAX_ALLOWED_CONTINUATION_PASSES.toFloat(), - steps = MAX_ALLOWED_CONTINUATION_PASSES - 1, - modifier = Modifier.testTag("max_handoffs_slider"), - ) - } - - // Live In-Dialog Warning Banner (Option C) - AnimatedVisibility( - visible = isHighLimit, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - Surface( - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f), - contentColor = MaterialTheme.colorScheme.onErrorContainer, - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)), - modifier = - Modifier - .fillMaxWidth() - .testTag("high_limits_warning_banner"), - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp), - ) - Text( - text = - stringResource( - R.string.group_chat_settings_high_limits_warning_desc, - maxMessages.roundToInt(), - maxPasses.roundToInt(), - ), - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - - // Room Instructions / System Prompt - Column { - Text( - text = stringResource(R.string.group_chat_settings_system_prompt), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(R.string.group_chat_settings_system_prompt_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(6.dp)) - OutlinedTextField( - value = systemPrompt, - onValueChange = { systemPrompt = it }, - placeholder = { - Text( - text = stringResource(R.string.group_chat_settings_system_prompt_hint), - style = MaterialTheme.typography.bodySmall, - ) - }, - modifier = - Modifier - .fillMaxWidth() - .heightIn(min = 80.dp, max = 160.dp) - .testTag("room_system_prompt_input"), - textStyle = MaterialTheme.typography.bodyMedium, - maxLines = 4, - ) - } - } - }, - confirmButton = { - Button( - onClick = { - if (isHighLimit) { - showHighLimitsConfirmation = true - } else { - onSave( - maxMessages.roundToInt(), - maxPasses.roundToInt(), - systemPrompt.trim().ifBlank { null }, - ) - } - }, - modifier = Modifier.testTag("save_settings_button"), - ) { - Text(stringResource(R.string.group_chat_settings_save)) - } - }, - dismissButton = { - Row { - TextButton( - onClick = { - maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat() - maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat() - systemPrompt = "" - }, - ) { - Text(stringResource(R.string.group_chat_settings_reset_default)) - } - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.common_cancel)) - } - } - }, - ) - - if (showHighLimitsConfirmation) { - AlertDialog( - onDismissRequest = { showHighLimitsConfirmation = false }, - icon = { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - }, - title = { - Text( - text = stringResource(R.string.group_chat_settings_high_limits_warning_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - }, - text = { - Text( - text = - stringResource( - R.string.group_chat_settings_high_limits_warning_desc, - maxMessages.roundToInt(), - maxPasses.roundToInt(), - ), - style = MaterialTheme.typography.bodyMedium, - ) - }, - confirmButton = { - Button( - onClick = { - showHighLimitsConfirmation = false - onSave( - maxMessages.roundToInt(), - maxPasses.roundToInt(), - systemPrompt.trim().ifBlank { null }, - ) - }, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - modifier = Modifier.testTag("confirm_high_limits_button"), - ) { - Text(stringResource(R.string.group_chat_settings_high_limits_confirm)) - } - }, - dismissButton = { - TextButton( - onClick = { showHighLimitsConfirmation = false }, - modifier = Modifier.testTag("dismiss_high_limits_button"), - ) { - Text(stringResource(R.string.group_chat_settings_high_limits_adjust)) - } - }, - ) - } -} - -@Composable -private fun GroupMessageCard( - message: GroupChatMessage, - modifier: Modifier = Modifier, -) { - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - var copied by remember { mutableStateOf(false) } - - LaunchedEffect(copied) { - if (copied) { - delay(1500) - copied = false - } - } - - if (message.isSystem) { - val displayText = - when { - message.isPass -> { - val name = message.senderDisplayName.ifBlank { message.senderName } - stringResource(R.string.group_chat_bot_passed, name) - } - - message.text == STOPPED_SYSTEM_TEXT -> { - stringResource(R.string.group_chat_stopped_by_user) - } - - message.text == CAPPED_SYSTEM_TEXT -> { - stringResource(R.string.group_chat_capped_limit) - } - - else -> { - message.text - } - } - val isSystemRtl = remember(displayText) { BidiUtils.isRtlText(displayText) } - val systemDirection = if (isSystemRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - Box( - modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), - contentAlignment = Alignment.Center, - ) { - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (message.isPass) 0.5f else 0.6f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - ) { - CompositionLocalProvider(LocalLayoutDirection provides systemDirection) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (message.isPass) { - Icon( - imageVector = Icons.Filled.FastForward, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } - Text( - text = displayText, - style = - MaterialTheme.typography.labelSmall.copy( - textDirection = - if (isSystemRtl) { - TextDirection.Rtl - } else { - TextDirection.Ltr - }, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } else if (message.isUser) { - val isUserRtl = remember(message.text) { BidiUtils.isRtlText(message.text) } - val userBubbleDirection = if (isUserRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.fillMaxWidth(0.85f), - ) { - CompositionLocalProvider(LocalLayoutDirection provides userBubbleDirection) { - Column(modifier = Modifier.padding(12.dp)) { - SelectionContainer { - Text( - text = message.text, - style = - MaterialTheme.typography.bodyMedium.copy( - textDirection = - if (isUserRtl) { - TextDirection.Rtl - } else { - TextDirection.Ltr - }, - ), - color = MaterialTheme.colorScheme.onPrimary, - ) - } - Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry( - ClipEntry(ClipData.newPlainText(null, message.text)), - ) - } - copied = true - }, - modifier = Modifier.size(20.dp), - ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.content_desc_copy), - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), - ) - } - Spacer(modifier = Modifier.width(2.dp)) - Text( - text = - formatTimestamp( - message.timestamp, - DateFormat.is24HourFormat(LocalContext.current), - ), - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - } - } - } else { - val isBotNameRtl = remember(message.senderDisplayName) { BidiUtils.isRtlText(message.senderDisplayName) } - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - ) { - BotAvatar( - name = message.senderName, - avatar = message.avatarMeta, - size = 32.dp, - showPresence = false, - ) - Spacer(modifier = Modifier.width(8.dp)) - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.weight(1f, fill = false), - ) { - Column(modifier = Modifier.padding(12.dp)) { - Text( - text = message.senderDisplayName, - style = - MaterialTheme.typography.labelMedium.copy( - textDirection = - if (isBotNameRtl) { - TextDirection.Rtl - } else { - TextDirection.Ltr - }, - ), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - if (message.toolCalls.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(bottom = if (message.text.isNotBlank()) 4.dp else 0.dp), - ) { - message.toolCalls.forEach { tool -> - GroupChatToolChip(tool = tool) - } - } - } - Spacer(modifier = Modifier.height(4.dp)) - if (message.isStreaming && message.text.isBlank() && message.toolCalls.none { it.isRunning }) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 4.dp), - ) { - CircularProgressIndicator( - modifier = Modifier.size(12.dp), - strokeWidth = 1.5.dp, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(R.string.group_chat_typing), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } - } else if (message.text.isNotBlank()) { - SelectionContainer { - MarkdownText( - text = message.text, - textColor = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - if (!message.isStreaming && message.text.isNotBlank()) { - Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry( - ClipEntry(ClipData.newPlainText(null, message.text)), - ) - } - copied = true - }, - modifier = Modifier.size(20.dp), - ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.content_desc_copy), - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } - Spacer(modifier = Modifier.width(2.dp)) - Text( - text = - formatTimestamp( - message.timestamp, - DateFormat.is24HourFormat(LocalContext.current), - ), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - } - } - } -} - -@Composable -private fun GroupChatToolChip( - tool: GroupChatToolCall, - modifier: Modifier = Modifier, -) { - var expanded by rememberSaveable(tool.id) { mutableStateOf(false) } - val isTerminal = - tool.name.equals("terminal", ignoreCase = true) || - tool.name.equals("execute_code", ignoreCase = true) - - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - border = - BorderStroke( - 1.dp, - if (tool.isError) { - MaterialTheme.colorScheme.error.copy(alpha = 0.5f) - } else { - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) - }, - ), - modifier = - modifier - .fillMaxWidth() - .clickable { expanded = !expanded }, - ) { - Column( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.fillMaxWidth(), - ) { - val icon = - when (tool.name.lowercase()) { - "terminal", "execute_code" -> Icons.Filled.Terminal - "web_search", "search_files" -> Icons.Filled.Search - "read_file", "write_file", "patch" -> Icons.Filled.Description - else -> Icons.Filled.Build - } - Icon( - imageVector = icon, - contentDescription = tool.name, - modifier = Modifier.size(13.dp), - tint = - if (tool.isError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - }, - ) - val label = - if (!tool.summary.isNullOrBlank()) { - "${tool.name}: ${tool.summary}" - } else { - tool.name - } - val isLabelRtl = remember(label) { BidiUtils.isRtlText(label) } - Text( - text = label, - style = - MaterialTheme.typography.labelSmall.copy( - textDirection = if (isLabelRtl) TextDirection.Rtl else TextDirection.Ltr, - ), - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - if (tool.isRunning) { - CircularProgressIndicator( - modifier = Modifier.size(10.dp), - strokeWidth = 1.5.dp, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Icon( - imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - } - } - - AnimatedVisibility( - visible = expanded, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 6.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (!tool.command.isNullOrBlank()) { - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - modifier = Modifier.fillMaxWidth(), - ) { - SelectionContainer { - Text( - text = if (isTerminal) "$ ${tool.command}" else tool.command, - style = - MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - textDirection = TextDirection.Ltr, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), - ) - } - } - } - - val outputText = - when { - tool.isRunning -> stringResource(R.string.group_chat_tool_running) - !tool.output.isNullOrBlank() -> tool.output.trim() - tool.exitCode == 0 -> stringResource(R.string.group_chat_tool_no_output_success) - else -> stringResource(R.string.group_chat_tool_no_output) - } - val isOutputRtl = remember(outputText) { BidiUtils.isRtlText(outputText) } - - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.6f), - modifier = Modifier.fillMaxWidth(), - ) { - SelectionContainer { - Text( - text = outputText, - style = - MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - textDirection = - if (isOutputRtl) { - TextDirection.Rtl - } else { - TextDirection.Ltr - }, - ), - color = - if (tool.isError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurface - }, - maxLines = 15, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatComposer.kt new file mode 100644 index 00000000..befd213e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatComposer.kt @@ -0,0 +1,296 @@ +package com.m57.hermescontrol.ui.bots.group.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.ProfileInfo +import com.m57.hermescontrol.ui.chat.ChatInputPolicy +import com.m57.hermescontrol.ui.common.BotAvatar +import com.m57.hermescontrol.util.BidiUtils + +@Composable +fun GroupChatComposer( + inputFieldValue: TextFieldValue, + onInputValueChange: (TextFieldValue) -> Unit, + groupBots: List, + groupName: String, + activeSpeaker: String?, + isFocused: Boolean, + onFocusChanged: (Boolean) -> Unit, + onSend: () -> Unit, + onStop: () -> Unit, + modifier: Modifier = Modifier, +) { + val mentionQuery = + remember(inputFieldValue.text, inputFieldValue.selection) { + ChatInputPolicy.extractMentionQuery( + inputFieldValue.text, + inputFieldValue.selection.end, + ) + } + + val showAllOption = + remember(mentionQuery, groupBots) { + if (mentionQuery == null || groupBots.isEmpty()) { + false + } else { + "all".startsWith(mentionQuery, ignoreCase = true) || + "everyone".startsWith(mentionQuery, ignoreCase = true) + } + } + + val filteredBots = + remember(mentionQuery, groupBots) { + if (mentionQuery == null) { + emptyList() + } else { + groupBots.filter { bot -> + bot.name.startsWith(mentionQuery, ignoreCase = true) || + bot.effectiveTitle.contains(mentionQuery, ignoreCase = true) + } + } + } + + AnimatedVisibility( + visible = mentionQuery != null && (showAllOption || filteredBots.isNotEmpty()), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LazyRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + .testTag("group_chat_mention_row"), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showAllOption) { + item(key = "mention_all") { + SuggestionChip( + onClick = { + onInputValueChange(ChatInputPolicy.applyMention(inputFieldValue, "all")) + }, + label = { + Text( + text = "@all", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + }, + icon = { + Icon( + imageVector = Icons.Filled.Groups, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + }, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag("mention_chip_all"), + ) + } + } + + items(filteredBots, key = { it.name }) { bot -> + SuggestionChip( + onClick = { + onInputValueChange(ChatInputPolicy.applyMention(inputFieldValue, bot.name)) + }, + label = { + Text( + text = "@${bot.name}", + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.labelMedium, + ) + }, + icon = { + BotAvatar( + name = bot.name, + avatar = bot.botMeta()?.avatar, + size = 18.dp, + showPresence = false, + ) + }, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag("mention_chip_${bot.name}"), + ) + } + } + } + + // ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ── + val ambientLayoutDirection = LocalLayoutDirection.current + val inputLayoutDirection = + remember(inputFieldValue.text, ambientLayoutDirection) { + BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection) + } + val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl + + CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { + BasicTextField( + value = inputFieldValue, + onValueChange = onInputValueChange, + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .onFocusChanged { onFocusChanged(it.isFocused) } + .testTag("group_chat_input"), + textStyle = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + singleLine = false, + maxLines = 4, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { onSend() }), + decorationBox = { innerTextField -> + CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { + Surface( + shape = RoundedCornerShape(20.dp), + border = + BorderStroke( + width = if (isFocused) 2.dp else 1.dp, + color = + if (isFocused) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + }, + ), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f), + contentAlignment = + if (isInputRtl) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, + ) { + CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { + if (inputFieldValue.text.isEmpty()) { + Text( + text = + stringResource( + R.string.group_chat_composer_hint, + groupName, + ), + style = MaterialTheme.typography.bodyMedium, + color = + MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.6f, + ), + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + modifier = Modifier.fillMaxWidth(), + ) + } + innerTextField() + } + } + + Spacer(modifier = Modifier.width(4.dp)) + + if (activeSpeaker != null) { + IconButton( + onClick = onStop, + colors = + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + modifier = + Modifier + .size(36.dp) + .testTag("group_chat_composer_stop_button"), + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = stringResource(R.string.group_chat_action_stop), + modifier = Modifier.size(18.dp), + ) + } + } else { + IconButton( + onClick = onSend, + enabled = inputFieldValue.text.isNotBlank(), + colors = IconButtonDefaults.filledTonalIconButtonColors(), + modifier = + Modifier + .size(36.dp) + .testTag("group_chat_send_button"), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + modifier = Modifier.size(18.dp), + ) + } + } + } + } + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt new file mode 100644 index 00000000..5d1d26a2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt @@ -0,0 +1,333 @@ +package com.m57.hermescontrol.ui.bots.group.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.bots.group.DEFAULT_MAX_BOT_MESSAGES +import com.m57.hermescontrol.ui.bots.group.DEFAULT_MAX_CONTINUATION_PASSES +import com.m57.hermescontrol.ui.bots.group.MAX_ALLOWED_BOT_MESSAGES +import com.m57.hermescontrol.ui.bots.group.MAX_ALLOWED_CONTINUATION_PASSES +import com.m57.hermescontrol.ui.bots.group.WARN_MAX_BOT_MESSAGES +import com.m57.hermescontrol.ui.bots.group.WARN_MAX_CONTINUATION_PASSES +import kotlin.math.roundToInt + +@Composable +fun GroupChatSettingsDialog( + currentMaxMessages: Int, + currentMaxPasses: Int, + currentSystemPrompt: String?, + onDismiss: () -> Unit, + onSave: (Int, Int, String?) -> Unit, +) { + var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) } + var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) } + var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) } + var showHighLimitsConfirmation by remember { mutableStateOf(false) } + + val isHighLimit = + maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES || + maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.group_chat_settings_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Max Bot Messages Slider + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.group_chat_settings_max_messages), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "${maxMessages.roundToInt()}", + style = MaterialTheme.typography.labelLarge, + color = + if (maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + fontWeight = FontWeight.Bold, + ) + } + Text( + text = + stringResource( + R.string.group_chat_settings_max_messages_desc, + maxMessages.roundToInt(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = maxMessages, + onValueChange = { maxMessages = it }, + valueRange = 1f..MAX_ALLOWED_BOT_MESSAGES.toFloat(), + steps = MAX_ALLOWED_BOT_MESSAGES - 2, + modifier = Modifier.testTag("max_messages_slider"), + ) + } + + // Max Continuation Handoffs Slider + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.group_chat_settings_max_handoffs), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "${maxPasses.roundToInt()}", + style = MaterialTheme.typography.labelLarge, + color = + if (maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + fontWeight = FontWeight.Bold, + ) + } + Text( + text = + stringResource( + R.string.group_chat_settings_max_handoffs_desc, + maxPasses.roundToInt(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = maxPasses, + onValueChange = { maxPasses = it }, + valueRange = 0f..MAX_ALLOWED_CONTINUATION_PASSES.toFloat(), + steps = MAX_ALLOWED_CONTINUATION_PASSES - 1, + modifier = Modifier.testTag("max_handoffs_slider"), + ) + } + + // Live In-Dialog Warning Banner (Option C) + AnimatedVisibility( + visible = isHighLimit, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f), + contentColor = MaterialTheme.colorScheme.onErrorContainer, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)), + modifier = + Modifier + .fillMaxWidth() + .testTag("high_limits_warning_banner"), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + Text( + text = + stringResource( + R.string.group_chat_settings_high_limits_warning_desc, + maxMessages.roundToInt(), + maxPasses.roundToInt(), + ), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + // Room Instructions / System Prompt + Column { + Text( + text = stringResource(R.string.group_chat_settings_system_prompt), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.group_chat_settings_system_prompt_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(6.dp)) + OutlinedTextField( + value = systemPrompt, + onValueChange = { systemPrompt = it }, + placeholder = { + Text( + text = stringResource(R.string.group_chat_settings_system_prompt_hint), + style = MaterialTheme.typography.bodySmall, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 80.dp, max = 160.dp) + .testTag("room_system_prompt_input"), + textStyle = MaterialTheme.typography.bodyMedium, + maxLines = 4, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + if (isHighLimit) { + showHighLimitsConfirmation = true + } else { + onSave( + maxMessages.roundToInt(), + maxPasses.roundToInt(), + systemPrompt.trim().ifBlank { null }, + ) + } + }, + modifier = Modifier.testTag("save_settings_button"), + ) { + Text(stringResource(R.string.group_chat_settings_save)) + } + }, + dismissButton = { + Row { + TextButton( + onClick = { + maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat() + maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat() + systemPrompt = "" + }, + ) { + Text(stringResource(R.string.group_chat_settings_reset_default)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_cancel)) + } + } + }, + ) + + if (showHighLimitsConfirmation) { + AlertDialog( + onDismissRequest = { showHighLimitsConfirmation = false }, + icon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { + Text( + text = stringResource(R.string.group_chat_settings_high_limits_warning_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Text( + text = + stringResource( + R.string.group_chat_settings_high_limits_warning_desc, + maxMessages.roundToInt(), + maxPasses.roundToInt(), + ), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + Button( + onClick = { + showHighLimitsConfirmation = false + onSave( + maxMessages.roundToInt(), + maxPasses.roundToInt(), + systemPrompt.trim().ifBlank { null }, + ) + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + modifier = Modifier.testTag("confirm_high_limits_button"), + ) { + Text(stringResource(R.string.group_chat_settings_high_limits_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = { showHighLimitsConfirmation = false }, + modifier = Modifier.testTag("dismiss_high_limits_button"), + ) { + Text(stringResource(R.string.group_chat_settings_high_limits_adjust)) + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatToolChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatToolChip.kt new file mode 100644 index 00000000..c0fc5496 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatToolChip.kt @@ -0,0 +1,210 @@ +package com.m57.hermescontrol.ui.bots.group.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.bots.group.GroupChatToolCall +import com.m57.hermescontrol.util.BidiUtils + +@Composable +fun GroupChatToolChip( + tool: GroupChatToolCall, + modifier: Modifier = Modifier, +) { + var expanded by rememberSaveable(tool.id) { mutableStateOf(false) } + val isTerminal = + tool.name.equals("terminal", ignoreCase = true) || + tool.name.equals("execute_code", ignoreCase = true) + + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + border = + BorderStroke( + 1.dp, + if (tool.isError) { + MaterialTheme.colorScheme.error.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + }, + ), + modifier = + modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + ) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + val icon = + when (tool.name.lowercase()) { + "terminal", "execute_code" -> Icons.Filled.Terminal + "web_search", "search_files" -> Icons.Filled.Search + "read_file", "write_file", "patch" -> Icons.Filled.Description + else -> Icons.Filled.Build + } + Icon( + imageVector = icon, + contentDescription = tool.name, + modifier = Modifier.size(13.dp), + tint = + if (tool.isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + ) + val label = + if (!tool.summary.isNullOrBlank()) { + "${tool.name}: ${tool.summary}" + } else { + tool.name + } + val isLabelRtl = remember(label) { BidiUtils.isRtlText(label) } + Text( + text = label, + style = + MaterialTheme.typography.labelSmall.copy( + textDirection = if (isLabelRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (tool.isRunning) { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (!tool.command.isNullOrBlank()) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + ) { + SelectionContainer { + Text( + text = if (isTerminal) "$ ${tool.command}" else tool.command, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + textDirection = TextDirection.Ltr, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + } + } + + val outputText = + when { + tool.isRunning -> stringResource(R.string.group_chat_tool_running) + !tool.output.isNullOrBlank() -> tool.output.trim() + tool.exitCode == 0 -> stringResource(R.string.group_chat_tool_no_output_success) + else -> stringResource(R.string.group_chat_tool_no_output) + } + val isOutputRtl = remember(outputText) { BidiUtils.isRtlText(outputText) } + + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.6f), + modifier = Modifier.fillMaxWidth(), + ) { + SelectionContainer { + Text( + text = outputText, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + textDirection = + if (isOutputRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + color = + if (tool.isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 15, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt new file mode 100644 index 00000000..bc403a26 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt @@ -0,0 +1,313 @@ +package com.m57.hermescontrol.ui.bots.group.components + +import android.content.ClipData +import android.text.format.DateFormat +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.bots.group.CAPPED_SYSTEM_TEXT +import com.m57.hermescontrol.ui.bots.group.GroupChatMessage +import com.m57.hermescontrol.ui.bots.group.STOPPED_SYSTEM_TEXT +import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.formatTimestamp +import com.m57.hermescontrol.ui.common.BotAvatar +import com.m57.hermescontrol.util.BidiUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun GroupMessageCard( + message: GroupChatMessage, + modifier: Modifier = Modifier, +) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(1500) + copied = false + } + } + + if (message.isSystem) { + val displayText = + when { + message.isPass -> { + val name = message.senderDisplayName.ifBlank { message.senderName } + stringResource(R.string.group_chat_bot_passed, name) + } + + message.text == STOPPED_SYSTEM_TEXT -> { + stringResource(R.string.group_chat_stopped_by_user) + } + + message.text == CAPPED_SYSTEM_TEXT -> { + stringResource(R.string.group_chat_capped_limit) + } + + else -> { + message.text + } + } + val isSystemRtl = remember(displayText) { BidiUtils.isRtlText(displayText) } + val systemDirection = if (isSystemRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + Box( + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (message.isPass) 0.5f else 0.6f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + ) { + CompositionLocalProvider(LocalLayoutDirection provides systemDirection) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (message.isPass) { + Icon( + imageVector = Icons.Filled.FastForward, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + Text( + text = displayText, + style = + MaterialTheme.typography.labelSmall.copy( + textDirection = + if (isSystemRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } else if (message.isUser) { + val isUserRtl = remember(message.text) { BidiUtils.isRtlText(message.text) } + val userBubbleDirection = if (isUserRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth(0.85f), + ) { + CompositionLocalProvider(LocalLayoutDirection provides userBubbleDirection) { + Column(modifier = Modifier.padding(12.dp)) { + SelectionContainer { + Text( + text = message.text, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = + if (isUserRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + color = MaterialTheme.colorScheme.onPrimary, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.align(Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(null, message.text)), + ) + } + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + ) + } + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = + formatTimestamp( + message.timestamp, + DateFormat.is24HourFormat(LocalContext.current), + ), + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + } + } else { + val isBotNameRtl = remember(message.senderDisplayName) { BidiUtils.isRtlText(message.senderDisplayName) } + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + ) { + BotAvatar( + name = message.senderName, + avatar = message.avatarMeta, + size = 32.dp, + showPresence = false, + ) + Spacer(modifier = Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.weight(1f, fill = false), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = message.senderDisplayName, + style = + MaterialTheme.typography.labelMedium.copy( + textDirection = + if (isBotNameRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + if (message.toolCalls.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(bottom = if (message.text.isNotBlank()) 4.dp else 0.dp), + ) { + message.toolCalls.forEach { tool -> + GroupChatToolChip(tool = tool) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + if (message.isStreaming && message.text.isBlank() && message.toolCalls.none { it.isRunning }) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.group_chat_typing), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } else if (message.text.isNotBlank()) { + SelectionContainer { + MarkdownText( + text = message.text, + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (!message.isStreaming && message.text.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.align(Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(null, message.text)), + ) + } + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = + formatTimestamp( + message.timestamp, + DateFormat.is24HourFormat(LocalContext.current), + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + } + } +} From f4904653d1148d2a0d8f62dcb09584b8447fe3b7 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 19:53:16 +0300 Subject: [PATCH 26/42] refactor(mcp): decompose McpServersScreen into components Extract McpSectionHeader, AddServerSection, ServerCard, CatalogSection, and McpDialogs into ui/mcp/components. --- .../hermescontrol/ui/mcp/McpServersScreen.kt | 849 +----------------- .../ui/mcp/components/AddServerSection.kt | 198 ++++ .../ui/mcp/components/CatalogSection.kt | 235 +++++ .../ui/mcp/components/McpDialogs.kt | 202 +++++ .../ui/mcp/components/McpSectionHeader.kt | 59 ++ .../ui/mcp/components/ServerCard.kt | 365 ++++++++ 6 files changed, 1071 insertions(+), 837 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index 8548b5c7..df7e9748 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -87,6 +87,10 @@ import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.mcp.components.AddServerSection +import com.m57.hermescontrol.ui.mcp.components.CatalogSection +import com.m57.hermescontrol.ui.mcp.components.McpDialogs +import com.m57.hermescontrol.ui.mcp.components.ServerCard @Composable fun McpServersScreen( @@ -247,842 +251,13 @@ fun McpServersScreen( } } - showDetail?.let { server -> - DetailDialog( - title = server.name, - rows = server.toDetailRows(), - onDismiss = { showDetail = null }, - ) - } - - state.activeOAuthFlow?.let { flow -> - androidx.compose.material3.AlertDialog( - onDismissRequest = { viewModel.dismissOAuthFlow() }, - confirmButton = { - Button( - onClick = { - flow.authorizationUrl?.let { url -> - try { - context.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse(url), - ), - ) - } catch (_: Exception) { - } - } - }, - ) { - Text(stringResource(R.string.mcp_servers_oauth_dialog_open_browser)) - } - }, - dismissButton = { - OutlinedButton(onClick = { viewModel.dismissOAuthFlow() }) { - Text(stringResource(R.string.action_cancel)) - } - }, - title = { - Text(stringResource(R.string.mcp_servers_oauth_dialog_title)) - }, - text = { - Column { - Text(stringResource(R.string.mcp_servers_oauth_dialog_desc)) - Spacer(modifier = Modifier.height(spacing.sm)) - Text( - text = stringResource(R.string.mcp_servers_oauth_dialog_status, flow.status), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyMedium, - ) - flow.error?.let { err -> - Spacer(modifier = Modifier.height(spacing.xs)) - Text( - text = stringResource(R.string.mcp_servers_oauth_dialog_error, err), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - } - }, - ) - } - - if (state.showImportDialog) { - androidx.compose.material3.AlertDialog( - onDismissRequest = { viewModel.toggleImportDialog() }, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Filled.UploadFile, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - Text(stringResource(R.string.mcp_servers_import_dialog_title)) - } - }, - text = { - Column { - Text( - text = stringResource(R.string.mcp_servers_import_dialog_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - OutlinedButton( - onClick = { - val clip = clipboardManager.getText()?.text - if (!clip.isNullOrBlank()) { - viewModel.updateImportJsonInput(clip) - } - }, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), - ) { - Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - text = stringResource(R.string.mcp_servers_import_dialog_paste), - style = MaterialTheme.typography.labelSmall, - ) - } - } - Spacer(modifier = Modifier.height(spacing.xs)) - OutlinedTextField( - value = state.importJsonInput, - onValueChange = viewModel::updateImportJsonInput, - placeholder = { - Text( - "{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}", - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - ) - }, - textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - modifier = - Modifier - .fillMaxWidth() - .height(180.dp), - maxLines = 10, - ) - } - }, - confirmButton = { - Button( - onClick = { viewModel.submitImportJson() }, - enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(), - ) { - if (state.isImportingJson) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_import_dialog_importing)) - } else { - Text(stringResource(R.string.mcp_servers_action_submit)) - } - } - }, - dismissButton = { - OutlinedButton(onClick = { viewModel.toggleImportDialog() }) { - Text(stringResource(R.string.action_cancel)) - } - }, - ) - } -} - -// ── Section header (MCP-specific: distinct icon + neutral title so it reads -// as a header, not a button) ────────────────────────────────────────────── -@Composable -private fun McpSectionHeader( - icon: ImageVector, - title: String, - trailing: (@Composable () -> Unit)? = null, -) { - val spacing = LocalSpacing.current - Row( - modifier = - Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = RoundedCornerShape(12.dp), - ).padding(horizontal = spacing.md, vertical = spacing.sm), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - ) - } - trailing?.invoke() - } -} - -// ── Sections ────────────────────────────────────────────────────── - -@Composable -private fun AddServerSection( - state: McpServersUiState, - viewModel: McpServersViewModel, - spacing: com.m57.hermescontrol.theme.Spacing, -) { - McpSectionHeader( - icon = Icons.Filled.Add, - title = stringResource(R.string.mcp_servers_add_server), - trailing = { - Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { - TextButton( - onClick = { viewModel.toggleImportDialog() }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_action_import_json)) - } - TextButton( - onClick = { viewModel.toggleAddForm() }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), - ) { - Text(if (state.showAddForm) "Hide" else "New") - } - } - }, + McpDialogs( + state = state, + viewModel = viewModel, + spacing = spacing, + selectedServerDetail = showDetail, + onDismissDetail = { showDetail = null }, + context = context, + clipboardManager = clipboardManager, ) - - AnimatedVisibility(visible = state.showAddForm) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - SecondaryTabRow(selectedTabIndex = if (state.addMode == AddServerMode.HTTP) 0 else 1) { - Tab( - selected = state.addMode == AddServerMode.HTTP, - onClick = { viewModel.setAddMode(AddServerMode.HTTP) }, - text = { Text(stringResource(R.string.mcp_servers_add_http)) }, - ) - Tab( - selected = state.addMode == AddServerMode.Stdio, - onClick = { viewModel.setAddMode(AddServerMode.Stdio) }, - text = { Text(stringResource(R.string.mcp_servers_add_stdio)) }, - ) - } - - Spacer(modifier = Modifier.height(spacing.sm)) - - OutlinedTextField( - value = state.addServerName, - onValueChange = viewModel::updateAddServerName, - label = { Text(stringResource(R.string.mcp_servers_field_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(spacing.sm)) - - if (state.addMode == AddServerMode.HTTP) { - OutlinedTextField( - value = state.addServerUrl, - onValueChange = viewModel::updateAddServerUrl, - label = { Text(stringResource(R.string.mcp_servers_field_url)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - ) - Spacer(modifier = Modifier.height(spacing.sm)) - Text( - text = stringResource(R.string.mcp_servers_auth_mode), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(spacing.xs)) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - verticalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - FilterChip( - selected = state.addServerAuth == "none", - onClick = { viewModel.updateAddServerAuth("none") }, - label = { Text(stringResource(R.string.mcp_servers_auth_none)) }, - ) - FilterChip( - selected = state.addServerAuth == "header", - onClick = { viewModel.updateAddServerAuth("header") }, - label = { Text(stringResource(R.string.mcp_servers_auth_header)) }, - ) - FilterChip( - selected = state.addServerAuth == "oauth", - onClick = { viewModel.updateAddServerAuth("oauth") }, - label = { Text(stringResource(R.string.mcp_servers_auth_oauth)) }, - ) - } - if (state.addServerAuth == "header") { - Spacer(modifier = Modifier.height(spacing.sm)) - OutlinedTextField( - value = state.addServerBearerToken, - onValueChange = viewModel::updateAddServerBearerToken, - label = { Text(stringResource(R.string.mcp_servers_field_bearer_token)) }, - singleLine = true, - visualTransformation = remember { PasswordVisualTransformation() }, - modifier = Modifier.fillMaxWidth(), - ) - } - } else { - OutlinedTextField( - value = state.addServerCommand, - onValueChange = viewModel::updateAddServerCommand, - label = { Text(stringResource(R.string.mcp_servers_field_command)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(spacing.sm)) - OutlinedTextField( - value = state.addServerArgs, - onValueChange = viewModel::updateAddServerArgs, - label = { Text(stringResource(R.string.mcp_servers_field_args)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - - Spacer(modifier = Modifier.height(spacing.md)) - Button( - onClick = { viewModel.submitAddServer() }, - enabled = !state.addingServer, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - if (state.addingServer) { - stringResource(R.string.mcp_servers_action_adding) - } else { - stringResource(R.string.mcp_servers_action_submit) - }, - ) - } - } - } - } -} - -@Composable -private fun ServerCard( - server: McpServer, - state: McpServersUiState, - viewModel: McpServersViewModel, - spacing: com.m57.hermescontrol.theme.Spacing, - onClick: () -> Unit, - onOpenBrowser: (String) -> Unit, -) { - var showEnv by remember { mutableStateOf(false) } - val isTesting = server.name in state.testingServers - val testResult = state.serverTestResults[server.name] - val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error) - - Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - colors = - CardDefaults.cardColors( - containerColor = - if (server.enabled) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Column(modifier = Modifier.fillMaxWidth().padding(spacing.md)) { - // Header row: name + status + toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = server.name.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - when (healthStatus) { - McpHealthStatus.HEALTHY -> { - StatusBadge( - text = stringResource(R.string.mcp_servers_status_healthy), - status = StatusBadgeType.SUCCESS, - ) - } - - McpHealthStatus.AUTH_REQUIRED -> { - StatusBadge( - text = stringResource(R.string.mcp_servers_status_needs_auth), - status = StatusBadgeType.WARNING, - ) - } - - McpHealthStatus.ERROR -> { - StatusBadge( - text = stringResource(R.string.mcp_servers_status_error), - status = StatusBadgeType.ERROR, - ) - } - - McpHealthStatus.TESTING -> { - StatusBadge( - text = stringResource(R.string.mcp_servers_status_testing), - status = StatusBadgeType.INFO, - ) - } - - McpHealthStatus.UNKNOWN -> { - ServerStatusBadge(server.status) - } - } - } - Text( - text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - // Tool count & schema token overhead badge (issue #1029) - val toolInfos = testResult?.tools - val toolCount = toolInfos?.size ?: server.tools?.size - if (toolCount != null && toolCount > 0) { - val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) } - val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst) - Spacer(modifier = Modifier.height(spacing.xs)) - Surface( - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), - shape = RoundedCornerShape(6.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp), - ) { - Icon( - imageVector = Icons.Filled.Build, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) - Spacer(modifier = Modifier.width(spacing.xs)) - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - fontWeight = FontWeight.Medium, - ) - } - } - } - } - Switch( - checked = server.enabled, - onCheckedChange = { viewModel.toggleServer(server) }, - ) - } - - // URL or Command - if (server.url != null) { - Spacer(modifier = Modifier.height(spacing.sm)) - Text( - text = server.url, - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - ) - } else if (server.command != null) { - Spacer(modifier = Modifier.height(spacing.sm)) - Text( - text = stringResource(R.string.mcp_servers_label_command), - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = "${server.command} ${server.args.orEmpty().joinToString(" ")}", - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - ) - } - - // Error diagnostics - server.error?.let { error -> - if (error.isNotBlank()) { - Spacer(modifier = Modifier.height(spacing.sm)) - StatusBadge( - text = error, - status = StatusBadgeType.ERROR, - ) - } - } - - Spacer(modifier = Modifier.height(spacing.sm)) - - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - if (server.auth == "oauth") { - FilledTonalButton( - onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) }, - modifier = Modifier.weight(1.2f), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), - ) { - Icon(Icons.Filled.Key, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_action_authorize), maxLines = 1) - } - } - FilledTonalButton( - onClick = { viewModel.testServer(server.name) }, - enabled = !isTesting, - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), - ) { - if (isTesting) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1) - } else { - Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1) - } - } - IconButton(onClick = { viewModel.deleteServer(server.name) }) { - Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) - } - } - - // Env vars toggle - TextButton( - onClick = { showEnv = !showEnv }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Filled.Storage, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_env_vars)) - } - - AnimatedVisibility(visible = showEnv) { - EnvVarSection(server = server, state = state, viewModel = viewModel, spacing = spacing) - } - } - } -} - -@Composable -private fun EnvVarSection( - server: McpServer, - state: McpServersUiState, - viewModel: McpServersViewModel, - spacing: com.m57.hermescontrol.theme.Spacing, -) { - val isEditing = state.editingEnvFor == server.name - val env = server.env ?: emptyMap() - - if (env.isEmpty() && !isEditing) { - Text( - text = stringResource(R.string.mcp_servers_env_no_vars), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = spacing.xs), - ) - } else { - env.forEach { (key, value) -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = key, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold) - Text( - text = if (value.length > 30) "${value.take(15)}…${value.takeLast(10)}" else value, - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) { - Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp)) - } - } - } - } - - if (isEditing) { - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = state.envKeyInput, - onValueChange = viewModel::updateEnvKey, - label = { Text(stringResource(R.string.mcp_servers_env_key)) }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - OutlinedTextField( - value = state.envValueInput, - onValueChange = viewModel::updateEnvValue, - label = { Text(stringResource(R.string.mcp_servers_env_value)) }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - } - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - Button(onClick = { viewModel.addEnvVar(server.name) }) { - Text(stringResource(R.string.mcp_servers_env_add)) - } - OutlinedButton(onClick = { viewModel.stopEditingEnv() }) { - Text(stringResource(R.string.action_cancel)) - } - } - } else { - TextButton(onClick = { viewModel.startEditingEnv(server) }) { - Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_env_add)) - } - } -} - -@Composable -private fun ServerStatusBadge(status: String?) { - if (status == null) return - val badgeType = - when (status.lowercase()) { - "running", "ok", "connected" -> StatusBadgeType.SUCCESS - "error", "failed" -> StatusBadgeType.ERROR - "stopped" -> StatusBadgeType.NEUTRAL - else -> StatusBadgeType.NEUTRAL - } - StatusBadge(text = status, status = badgeType) -} - -// ── Catalog ───────────────────────────────────────────────────── - -@Composable -private fun CatalogSection( - state: McpServersUiState, - viewModel: McpServersViewModel, - spacing: com.m57.hermescontrol.theme.Spacing, - filteredCatalog: List, -) { - val catalogExpanded = remember { mutableStateOf(false) } - - McpSectionHeader( - icon = Icons.Filled.Storage, - title = stringResource(R.string.mcp_servers_section_catalog), - trailing = { - TextButton( - onClick = { - catalogExpanded.value = !catalogExpanded.value - if (catalogExpanded.value && state.catalogEntries.isEmpty() && !state.catalogLoading) { - viewModel.loadCatalog() - } - }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), - ) { - Text(if (catalogExpanded.value) "Hide" else "Browse") - } - }, - ) - - AnimatedVisibility(visible = catalogExpanded.value) { - Column(modifier = Modifier.fillMaxWidth()) { - // Search - OutlinedTextField( - value = state.catalogQuery, - onValueChange = viewModel::updateCatalogQuery, - label = { Text(stringResource(R.string.mcp_servers_catalog_search)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm), - ) - - // Loading / error / content - when { - state.catalogLoading -> { - Spacer(modifier = Modifier.height(spacing.sm)) - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - Spacer(modifier = Modifier.height(spacing.sm)) - } - - state.catalogError != null -> { - ErrorState( - message = state.catalogError, - onRetry = { viewModel.loadCatalog() }, - ) - } - - filteredCatalog.isEmpty() -> { - Text( - text = stringResource(R.string.mcp_servers_catalog_empty), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = spacing.md), - ) - } - - else -> { - filteredCatalog.forEach { entry -> - CatalogEntryCard( - entry = entry, - state = state, - viewModel = viewModel, - spacing = spacing, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - } - } - } - } - } -} - -@Composable -private fun CatalogEntryCard( - entry: McpCatalogEntry, - state: McpServersUiState, - viewModel: McpServersViewModel, - spacing: com.m57.hermescontrol.theme.Spacing, -) { - var showInstallForm by remember { mutableStateOf(false) } - val isInstalling = state.installingCatalogEntry == entry.name - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = entry.name, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - entry.source?.let { - Text( - text = stringResource(R.string.mcp_servers_catalog_source, it), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Button( - onClick = { - if (entry.env?.isNotEmpty() == true) { - showInstallForm = !showInstallForm - } else { - viewModel.installCatalogEntry(entry) - } - }, - enabled = !isInstalling, - ) { - Text( - if (isInstalling) { - stringResource(R.string.mcp_servers_catalog_installing) - } else { - stringResource(R.string.mcp_servers_catalog_install) - }, - ) - } - } - - entry.description?.let { - Spacer(modifier = Modifier.height(spacing.xs)) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // Install form with env vars - AnimatedVisibility(visible = showInstallForm) { - Column(modifier = Modifier.padding(top = spacing.sm)) { - entry.env?.let { envVars -> - if (envVars.isNotEmpty()) { - Text( - text = stringResource(R.string.mcp_servers_catalog_required_env), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - envVars.forEach { envVar -> - val label = envVar.label ?: envVar.key - val currentValue = state.catalogInstallEnv[envVar.key] ?: "" - OutlinedTextField( - value = currentValue, - onValueChange = { viewModel.updateCatalogEnvVar(envVar.key, it) }, - label = { Text(label) }, - placeholder = envVar.description?.let { { Text(it) } }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(spacing.sm)) - } - } - } - Button( - onClick = { viewModel.installCatalogEntry(entry) }, - enabled = !isInstalling, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - if (isInstalling) { - stringResource(R.string.mcp_servers_catalog_installing) - } else { - stringResource(R.string.mcp_servers_catalog_install) - }, - ) - } - } - } - } - } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt new file mode 100644 index 00000000..4bae7bf1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt @@ -0,0 +1,198 @@ +package com.m57.hermescontrol.ui.mcp.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.mcp.AddServerMode +import com.m57.hermescontrol.ui.mcp.McpServersUiState +import com.m57.hermescontrol.ui.mcp.McpServersViewModel + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AddServerSection( + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: Spacing, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + McpSectionHeader( + icon = Icons.Filled.Add, + title = stringResource(R.string.mcp_servers_add_server), + trailing = { + Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { + TextButton( + onClick = { viewModel.toggleImportDialog() }, + colors = + ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_action_import_json)) + } + TextButton( + onClick = { viewModel.toggleAddForm() }, + colors = + ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Text(if (state.showAddForm) "Hide" else "New") + } + } + }, + ) + + AnimatedVisibility(visible = state.showAddForm) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + SecondaryTabRow(selectedTabIndex = if (state.addMode == AddServerMode.HTTP) 0 else 1) { + Tab( + selected = state.addMode == AddServerMode.HTTP, + onClick = { viewModel.setAddMode(AddServerMode.HTTP) }, + text = { Text(stringResource(R.string.mcp_servers_add_http)) }, + ) + Tab( + selected = state.addMode == AddServerMode.Stdio, + onClick = { viewModel.setAddMode(AddServerMode.Stdio) }, + text = { Text(stringResource(R.string.mcp_servers_add_stdio)) }, + ) + } + + Spacer(modifier = Modifier.height(spacing.sm)) + + OutlinedTextField( + value = state.addServerName, + onValueChange = viewModel::updateAddServerName, + label = { Text(stringResource(R.string.mcp_servers_field_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(spacing.sm)) + + if (state.addMode == AddServerMode.HTTP) { + OutlinedTextField( + value = state.addServerUrl, + onValueChange = viewModel::updateAddServerUrl, + label = { Text(stringResource(R.string.mcp_servers_field_url)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + ) + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = stringResource(R.string.mcp_servers_auth_mode), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(spacing.xs)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + verticalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + FilterChip( + selected = state.addServerAuth == "none", + onClick = { viewModel.updateAddServerAuth("none") }, + label = { Text(stringResource(R.string.mcp_servers_auth_none)) }, + ) + FilterChip( + selected = state.addServerAuth == "header", + onClick = { viewModel.updateAddServerAuth("header") }, + label = { Text(stringResource(R.string.mcp_servers_auth_header)) }, + ) + FilterChip( + selected = state.addServerAuth == "oauth", + onClick = { viewModel.updateAddServerAuth("oauth") }, + label = { Text(stringResource(R.string.mcp_servers_auth_oauth)) }, + ) + } + if (state.addServerAuth == "header") { + Spacer(modifier = Modifier.height(spacing.sm)) + OutlinedTextField( + value = state.addServerBearerToken, + onValueChange = viewModel::updateAddServerBearerToken, + label = { Text(stringResource(R.string.mcp_servers_field_bearer_token)) }, + singleLine = true, + visualTransformation = remember { PasswordVisualTransformation() }, + modifier = Modifier.fillMaxWidth(), + ) + } + } else { + OutlinedTextField( + value = state.addServerCommand, + onValueChange = viewModel::updateAddServerCommand, + label = { Text(stringResource(R.string.mcp_servers_field_command)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(spacing.sm)) + OutlinedTextField( + value = state.addServerArgs, + onValueChange = viewModel::updateAddServerArgs, + label = { Text(stringResource(R.string.mcp_servers_field_args)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(spacing.md)) + Button( + onClick = { viewModel.submitAddServer() }, + enabled = !state.addingServer, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (state.addingServer) { + stringResource(R.string.mcp_servers_action_adding) + } else { + stringResource(R.string.mcp_servers_action_submit) + }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt new file mode 100644 index 00000000..533123d2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt @@ -0,0 +1,235 @@ +package com.m57.hermescontrol.ui.mcp.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.McpCatalogEntry +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.mcp.McpServersUiState +import com.m57.hermescontrol.ui.mcp.McpServersViewModel + +@Composable +fun CatalogSection( + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: Spacing, + filteredCatalog: List, + modifier: Modifier = Modifier, +) { + val catalogExpanded = remember { mutableStateOf(false) } + + Column(modifier = modifier) { + McpSectionHeader( + icon = Icons.Filled.Storage, + title = stringResource(R.string.mcp_servers_section_catalog), + trailing = { + TextButton( + onClick = { + catalogExpanded.value = !catalogExpanded.value + if (catalogExpanded.value && state.catalogEntries.isEmpty() && !state.catalogLoading) { + viewModel.loadCatalog() + } + }, + colors = + ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Text(if (catalogExpanded.value) "Hide" else "Browse") + } + }, + ) + + AnimatedVisibility(visible = catalogExpanded.value) { + Column(modifier = Modifier.fillMaxWidth()) { + // Search + OutlinedTextField( + value = state.catalogQuery, + onValueChange = viewModel::updateCatalogQuery, + label = { Text(stringResource(R.string.mcp_servers_catalog_search)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm), + ) + + // Loading / error / content + when { + state.catalogLoading -> { + Spacer(modifier = Modifier.height(spacing.sm)) + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + Spacer(modifier = Modifier.height(spacing.sm)) + } + + state.catalogError != null -> { + ErrorState( + message = state.catalogError, + onRetry = { viewModel.loadCatalog() }, + ) + } + + filteredCatalog.isEmpty() -> { + Text( + text = stringResource(R.string.mcp_servers_catalog_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = spacing.md), + ) + } + + else -> { + filteredCatalog.forEach { entry -> + CatalogEntryCard( + entry = entry, + state = state, + viewModel = viewModel, + spacing = spacing, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + } + } + } + } + } + } +} + +@Composable +fun CatalogEntryCard( + entry: McpCatalogEntry, + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: Spacing, + modifier: Modifier = Modifier, +) { + var showInstallForm by remember { mutableStateOf(false) } + val isInstalling = state.installingCatalogEntry == entry.name + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + entry.source?.let { + Text( + text = stringResource(R.string.mcp_servers_catalog_source, it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Button( + onClick = { + if (entry.env?.isNotEmpty() == true) { + showInstallForm = !showInstallForm + } else { + viewModel.installCatalogEntry(entry) + } + }, + enabled = !isInstalling, + ) { + Text( + if (isInstalling) { + stringResource(R.string.mcp_servers_catalog_installing) + } else { + stringResource(R.string.mcp_servers_catalog_install) + }, + ) + } + } + + entry.description?.let { + Spacer(modifier = Modifier.height(spacing.xs)) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Install form with env vars + AnimatedVisibility(visible = showInstallForm) { + Column(modifier = Modifier.padding(top = spacing.sm)) { + entry.env?.let { envVars -> + if (envVars.isNotEmpty()) { + Text( + text = stringResource(R.string.mcp_servers_catalog_required_env), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + envVars.forEach { envVar -> + val label = envVar.label ?: envVar.key + val currentValue = state.catalogInstallEnv[envVar.key] ?: "" + OutlinedTextField( + value = currentValue, + onValueChange = { viewModel.updateCatalogEnvVar(envVar.key, it) }, + label = { Text(label) }, + placeholder = envVar.description?.let { { Text(it) } }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(spacing.sm)) + } + } + } + Button( + onClick = { viewModel.installCatalogEntry(entry) }, + enabled = !isInstalling, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (isInstalling) { + stringResource(R.string.mcp_servers_catalog_installing) + } else { + stringResource(R.string.mcp_servers_catalog_install) + }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt new file mode 100644 index 00000000..039c5ac0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt @@ -0,0 +1,202 @@ +package com.m57.hermescontrol.ui.mcp.components + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.DetailDialog +import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.mcp.McpServersUiState +import com.m57.hermescontrol.ui.mcp.McpServersViewModel + +@Composable +fun McpDialogs( + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: Spacing, + selectedServerDetail: McpServer?, + onDismissDetail: () -> Unit, + context: Context = LocalContext.current, + clipboardManager: ClipboardManager = LocalClipboardManager.current, +) { + selectedServerDetail?.let { server -> + DetailDialog( + title = server.name, + rows = server.toDetailRows(), + onDismiss = onDismissDetail, + ) + } + + state.activeOAuthFlow?.let { flow -> + AlertDialog( + onDismissRequest = { viewModel.dismissOAuthFlow() }, + confirmButton = { + Button( + onClick = { + flow.authorizationUrl?.let { url -> + try { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse(url), + ), + ) + } catch (_: Exception) { + } + } + }, + ) { + Text(stringResource(R.string.mcp_servers_oauth_dialog_open_browser)) + } + }, + dismissButton = { + OutlinedButton(onClick = { viewModel.dismissOAuthFlow() }) { + Text(stringResource(R.string.action_cancel)) + } + }, + title = { + Text(stringResource(R.string.mcp_servers_oauth_dialog_title)) + }, + text = { + Column { + Text(stringResource(R.string.mcp_servers_oauth_dialog_desc)) + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = stringResource(R.string.mcp_servers_oauth_dialog_status, flow.status), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + ) + flow.error?.let { err -> + Spacer(modifier = Modifier.height(spacing.xs)) + Text( + text = stringResource(R.string.mcp_servers_oauth_dialog_error, err), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + ) + } + + if (state.showImportDialog) { + AlertDialog( + onDismissRequest = { viewModel.toggleImportDialog() }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.UploadFile, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + Text(stringResource(R.string.mcp_servers_import_dialog_title)) + } + }, + text = { + Column { + Text( + text = stringResource(R.string.mcp_servers_import_dialog_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = { + val clip = clipboardManager.getText()?.text + if (!clip.isNullOrBlank()) { + viewModel.updateImportJsonInput(clip) + } + }, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + ) { + Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + text = stringResource(R.string.mcp_servers_import_dialog_paste), + style = MaterialTheme.typography.labelSmall, + ) + } + } + Spacer(modifier = Modifier.height(spacing.xs)) + OutlinedTextField( + value = state.importJsonInput, + onValueChange = viewModel::updateImportJsonInput, + placeholder = { + Text( + "{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}", + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + }, + textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = + Modifier + .fillMaxWidth() + .height(180.dp), + maxLines = 10, + ) + } + }, + confirmButton = { + Button( + onClick = { viewModel.submitImportJson() }, + enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(), + ) { + if (state.isImportingJson) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_import_dialog_importing)) + } else { + Text(stringResource(R.string.mcp_servers_action_submit)) + } + } + }, + dismissButton = { + OutlinedButton(onClick = { viewModel.toggleImportDialog() }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt new file mode 100644 index 00000000..9dd01b89 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt @@ -0,0 +1,59 @@ +package com.m57.hermescontrol.ui.mcp.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.theme.LocalSpacing + +@Composable +fun McpSectionHeader( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + trailing: (@Composable () -> Unit)? = null, +) { + val spacing = LocalSpacing.current + Row( + modifier = + modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(12.dp), + ).padding(horizontal = spacing.md, vertical = spacing.sm), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + ) + } + trailing?.invoke() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt new file mode 100644 index 00000000..155f6c32 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt @@ -0,0 +1,365 @@ +package com.m57.hermescontrol.ui.mcp.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Science +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.mcp.McpHealthStatus +import com.m57.hermescontrol.ui.mcp.McpServersUiState +import com.m57.hermescontrol.ui.mcp.McpServersViewModel +import com.m57.hermescontrol.ui.mcp.McpTokenEstimator + +@Composable +fun ServerCard( + server: McpServer, + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: Spacing, + onClick: () -> Unit, + onOpenBrowser: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var showEnv by remember { mutableStateOf(false) } + val isTesting = server.name in state.testingServers + val testResult = state.serverTestResults[server.name] + val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error) + + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = + if (server.enabled) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(spacing.md)) { + // Header row: name + status + toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = server.name.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + when (healthStatus) { + McpHealthStatus.HEALTHY -> { + StatusBadge( + text = stringResource(R.string.mcp_servers_status_healthy), + status = StatusBadgeType.SUCCESS, + ) + } + + McpHealthStatus.AUTH_REQUIRED -> { + StatusBadge( + text = stringResource(R.string.mcp_servers_status_needs_auth), + status = StatusBadgeType.WARNING, + ) + } + + McpHealthStatus.ERROR -> { + StatusBadge( + text = stringResource(R.string.mcp_servers_status_error), + status = StatusBadgeType.ERROR, + ) + } + + McpHealthStatus.TESTING -> { + StatusBadge( + text = stringResource(R.string.mcp_servers_status_testing), + status = StatusBadgeType.INFO, + ) + } + + McpHealthStatus.UNKNOWN -> { + ServerStatusBadge(server.status) + } + } + } + Text( + text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Tool count & schema token overhead badge (issue #1029) + val toolInfos = testResult?.tools + val toolCount = toolInfos?.size ?: server.tools?.size + if (toolCount != null && toolCount > 0) { + val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) } + val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst) + Spacer(modifier = Modifier.height(spacing.xs)) + Surface( + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), + shape = RoundedCornerShape(6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp), + ) { + Icon( + imageVector = Icons.Filled.Build, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontWeight = FontWeight.Medium, + ) + } + } + } + } + Switch( + checked = server.enabled, + onCheckedChange = { viewModel.toggleServer(server) }, + ) + } + + // URL or Command + if (server.url != null) { + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = server.url, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + ) + } else if (server.command != null) { + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = stringResource(R.string.mcp_servers_label_command), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${server.command} ${server.args.orEmpty().joinToString(" ")}", + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + ) + } + + // Error diagnostics + server.error?.let { error -> + if (error.isNotBlank()) { + Spacer(modifier = Modifier.height(spacing.sm)) + StatusBadge( + text = error, + status = StatusBadgeType.ERROR, + ) + } + } + + Spacer(modifier = Modifier.height(spacing.sm)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + if (server.auth == "oauth") { + FilledTonalButton( + onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) }, + modifier = Modifier.weight(1.2f), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + ) { + Icon(Icons.Filled.Key, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_action_authorize), maxLines = 1) + } + } + FilledTonalButton( + onClick = { viewModel.testServer(server.name) }, + enabled = !isTesting, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1) + } else { + Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1) + } + } + IconButton(onClick = { viewModel.deleteServer(server.name) }) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) + } + } + + // Env vars toggle + TextButton( + onClick = { showEnv = !showEnv }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Storage, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_env_vars)) + } + + AnimatedVisibility(visible = showEnv) { + EnvVarSection(server = server, state = state, viewModel = viewModel, spacing = spacing) + } + } + } +} + +@Composable +fun EnvVarSection( + server: McpServer, + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: Spacing, + modifier: Modifier = Modifier, +) { + val isEditing = state.editingEnvFor == server.name + val env = server.env ?: emptyMap() + + Column(modifier = modifier) { + if (env.isEmpty() && !isEditing) { + Text( + text = stringResource(R.string.mcp_servers_env_no_vars), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = spacing.xs), + ) + } else { + env.forEach { (key, value) -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = key, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold) + Text( + text = if (value.length > 30) "${value.take(15)}…${value.takeLast(10)}" else value, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) { + Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp)) + } + } + } + } + + if (isEditing) { + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = state.envKeyInput, + onValueChange = viewModel::updateEnvKey, + label = { Text(stringResource(R.string.mcp_servers_env_key)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = state.envValueInput, + onValueChange = viewModel::updateEnvValue, + label = { Text(stringResource(R.string.mcp_servers_env_value)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Button(onClick = { viewModel.addEnvVar(server.name) }) { + Text(stringResource(R.string.mcp_servers_env_add)) + } + OutlinedButton(onClick = { viewModel.stopEditingEnv() }) { + Text(stringResource(R.string.action_cancel)) + } + } + } else { + TextButton(onClick = { viewModel.startEditingEnv(server) }) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_env_add)) + } + } + } +} + +@Composable +fun ServerStatusBadge(status: String?) { + if (status == null) return + val badgeType = + when (status.lowercase()) { + "running", "ok", "connected" -> StatusBadgeType.SUCCESS + "error", "failed" -> StatusBadgeType.ERROR + "stopped" -> StatusBadgeType.NEUTRAL + else -> StatusBadgeType.NEUTRAL + } + StatusBadge(text = status, status = badgeType) +} From 5f79bdee54461d8dff7038fb149e744bf442a6a3 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 20:02:26 +0300 Subject: [PATCH 27/42] refactor(markdown): decompose MarkdownText UI and styling Extract MarkdownInlineStyler, MarkdownInlineText, MarkdownTable, and MarkdownMediaResolver into ui/chat/markdown package. --- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 506 +----------------- .../ui/chat/markdown/MarkdownInlineStyler.kt | 342 ++++++++++++ .../ui/chat/markdown/MarkdownInlineText.kt | 113 ++++ .../ui/chat/markdown/MarkdownMediaResolver.kt | 29 + .../ui/chat/markdown/MarkdownTableView.kt | 103 ++++ 5 files changed, 595 insertions(+), 498 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownMediaResolver.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index ffb7b3a4..6a1feeb2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -63,6 +63,10 @@ import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors import com.m57.hermescontrol.ui.chat.markdown.FnNote import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment +import com.m57.hermescontrol.ui.chat.markdown.MarkdownInlineStyler +import com.m57.hermescontrol.ui.chat.markdown.MarkdownInlineText +import com.m57.hermescontrol.ui.chat.markdown.MarkdownMediaResolver +import com.m57.hermescontrol.ui.chat.markdown.MarkdownTable import com.m57.hermescontrol.ui.chat.markdown.MdBlock import com.m57.hermescontrol.ui.chat.markdown.TableAlign import com.m57.hermescontrol.ui.chat.markdown.parseBlocks @@ -347,7 +351,7 @@ fun MarkdownText( } is MdBlock.Video -> { - val resolvedUri = remember(block.uri) { resolveImageUrl(block.uri) } + val resolvedUri = remember(block.uri) { MarkdownMediaResolver.resolveImageUrl(block.uri) } var showVideoDialog by remember { mutableStateOf(false) } androidx.compose.foundation.layout.Box( modifier = @@ -369,7 +373,7 @@ fun MarkdownText( } is MdBlock.Image -> { - val model: Any = remember(block.uri) { resolveImageUrl(block.uri) } + val model: Any = remember(block.uri) { MarkdownMediaResolver.resolveImageUrl(block.uri) } val isGif = remember(block.uri) { block.uri.contains(".gif", ignoreCase = true) || @@ -509,179 +513,6 @@ fun MarkdownText( } } -@Composable -private fun MarkdownInlineText( - text: String, - textColor: Color, - latexMeasurer: LatexMeasurerState, - style: TextStyle, - searchQuery: String, - isCurrentMatch: Boolean, - linkColor: Color, - highlights: SearchHighlightColors, - modifier: Modifier = Modifier, -) { - val isRtl = remember(text) { BidiUtils.isRtlText(text) } - val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - val resolvedStyle = - style.copy( - textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr, - ) - val processedText = - remember(text, isRtl) { - if (isRtl) BidiUtils.anchorTrailingRtl(text) else text - } - - CompositionLocalProvider(LocalLayoutDirection provides direction) { - val segments = remember(processedText) { splitInlineMath(processedText) } - if (segments.none { it is InlineMathSegment.Math }) { - Text( - text = - remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) { - parseInlineSource( - text = processedText, - textColor = textColor, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - isRtl = isRtl, - ) - }, - color = textColor, - style = resolvedStyle, - modifier = modifier, - ) - return@CompositionLocalProvider - } - - val config = - LatexConfig( - fontSize = style.fontSize, - theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent), - accessibilityEnabled = true, - ) - val inlineContent = mutableMapOf() - segments.forEachIndexed { index, segment -> - if (segment is InlineMathSegment.Math) { - latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it } - } - } - val annotated = - buildAnnotatedString { - segments.forEachIndexed { index, segment -> - when (segment) { - is InlineMathSegment.Math -> { - val id = "latex-$index" - if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex) - } - - is InlineMathSegment.Text -> { - append( - parseInlineSource( - text = segment.value, - textColor = textColor, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - isRtl = isRtl, - ), - ) - } - } - } - } - Text( - text = annotated, - inlineContent = inlineContent, - color = textColor, - style = resolvedStyle, - modifier = modifier, - ) - } -} - -@Composable -private fun MarkdownTable( - block: MdBlock.Table, - textColor: Color, -) { - val isRtl = - remember(block) { - block.header.any { BidiUtils.isRtlText(it) } || - block.rows.any { row -> row.any { BidiUtils.isRtlText(it) } } - } - val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - val headerBg = textColor.copy(alpha = 0.08f) - val alignments = block.alignments - CompositionLocalProvider(LocalLayoutDirection provides tableDirection) { - Column( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(vertical = 4.dp), - ) { - // Header row - Row(modifier = Modifier.background(headerBg)) { - block.header.forEachIndexed { idx, cell -> - val cellRtl = BidiUtils.isRtlText(cell) - Text( - text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, - textAlign = tableTextAlign(alignments.getOrNull(idx)), - color = textColor, - style = - MaterialTheme.typography.bodySmall.copy( - fontWeight = FontWeight.Bold, - textDirection = bidiTextDirection(cellRtl), - ), - modifier = - Modifier - .width(TABLE_COL_WIDTH) - .padding(6.dp), - ) - } - } - HorizontalDivider(color = textColor.copy(alpha = 0.25f)) - // Body rows - block.rows.forEach { row -> - Row { - row.forEachIndexed { idx, cell -> - val cellRtl = BidiUtils.isRtlText(cell) - Text( - text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, - textAlign = tableTextAlign(alignments.getOrNull(idx)), - color = textColor, - style = - MaterialTheme.typography.bodySmall.copy( - textDirection = bidiTextDirection(cellRtl), - ), - modifier = - Modifier - .width(TABLE_COL_WIDTH) - .padding(6.dp), - ) - } - } - HorizontalDivider(color = textColor.copy(alpha = 0.1f)) - } - } - } -} - -private fun tableTextAlign(align: TableAlign?): TextAlign? = - when (align) { - TableAlign.CENTER -> TextAlign.Center - TableAlign.RIGHT -> TextAlign.End - else -> null - } - -/** - * Inline Markdown -> AnnotatedString. Handles `code`, **bold**, *italic*, ***bold italic***, - * ~~strike~~, ==highlight==, ^sup^, ~sub~, keys, [^ref] footnotes, [text](url) and - * bare URLs, plus search-query highlighting. - */ internal fun parseInline( text: String, textColor: Color, @@ -691,14 +522,8 @@ internal fun parseInline( highlights: SearchHighlightColors, isRtl: Boolean = BidiUtils.isRtlText(text), ): AnnotatedString = - parseInlineSource( - text = - splitInlineMath(text).joinToString(separator = "") { segment -> - when (segment) { - is InlineMathSegment.Math -> segment.latex - is InlineMathSegment.Text -> segment.value - } - }, + MarkdownInlineStyler.parseInline( + text = text, textColor = textColor, searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, @@ -706,318 +531,3 @@ internal fun parseInline( highlights = highlights, isRtl = isRtl, ) - -private fun parseInlineSource( - text: String, - textColor: Color, - searchQuery: String, - isCurrentMatch: Boolean, - linkColor: Color, - highlights: SearchHighlightColors, - isRtl: Boolean = BidiUtils.isRtlText(text), -): AnnotatedString { - val searchHighlightColor = - if (isCurrentMatch) { - highlights.currentSearchBackground to highlights.currentSearchForeground - } else { - highlights.searchBackground to highlights.searchForeground - } - - return buildAnnotatedString { - var i = 0 - val src = text - while (i < src.length) { - when { - // ***bold italic*** - src.startsWith("***", i) -> { - val end = src.indexOf("***", i + 3) - if (end != -1) { - val raw = src.substring(i + 3, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { - append(toAppend) - } - i = end + 3 - } else { - append(src[i]) - i++ - } - } - - // **bold** - src.startsWith("**", i) -> { - val end = src.indexOf("**", i + 2) - if (end != -1) { - val raw = src.substring(i + 2, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(toAppend) - } - i = end + 2 - } else { - append(src[i]) - i++ - } - } - - // ~~strike~~ - src.startsWith("~~", i) -> { - val end = src.indexOf("~~", i + 2) - if (end != -1) { - val raw = src.substring(i + 2, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { - append(toAppend) - } - i = end + 2 - } else { - append(src[i]) - i++ - } - } - - // *italic* - src.startsWith("*", i) -> { - val end = src.indexOf('*', i + 1) - if (end != -1 && end > i + 1) { - val raw = src.substring(i + 1, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append(toAppend) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // ==highlight== - src.startsWith("==", i) -> { - val end = src.indexOf("==", i + 2) - if (end != -1) { - val raw = src.substring(i + 2, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(background = highlights.markupBackground)) { - append(toAppend) - } - i = end + 2 - } else { - append(src[i]) - i++ - } - } - - // ^superscript^ - src.startsWith("^", i) -> { - val end = src.indexOf('^', i + 1) - if (end != -1 && end > i + 1) { - withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { - append(src.substring(i + 1, end)) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // ~subscript~ (single tilde; ~~ handled above) - src.startsWith("~", i) -> { - val end = src.indexOf('~', i + 1) - if (end != -1 && end > i + 1) { - withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { - append(src.substring(i + 1, end)) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // key - src.startsWith("", i) -> { - val end = src.indexOf("", i) - if (end != -1) { - val raw = src.substring(i + 5, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - background = textColor.copy(alpha = 0.12f), - ), - ) { - append(toAppend) - } - i = end + 6 - } else { - append(src[i]) - i++ - } - } - - // [^ref] footnote marker -> superscript - src.startsWith("[^", i) -> { - val close = src.indexOf(']', i) - if (close != -1) { - val id = src.substring(i + 2, close) - withStyle( - SpanStyle( - baselineShift = BaselineShift.Superscript, - color = linkColor, - fontWeight = FontWeight.Bold, - ), - ) { - append("[$id]") - } - i = close + 1 - } else { - append(src[i]) - i++ - } - } - - // [text](url) - src.startsWith("[", i) -> { - val close = src.indexOf(']', i) - if (close != -1 && close + 1 < src.length && src[close + 1] == '(') { - val urlEnd = src.indexOf(')', close + 2) - if (urlEnd != -1) { - val label = src.substring(i + 1, close) - val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label - val url = src.substring(close + 2, urlEnd) - pushLink(LinkAnnotation.Url(url)) - withStyle( - SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline, - ), - ) { - append(labelToAppend) - } - pop() - i = urlEnd + 1 - } else { - append(src[i]) - i++ - } - } else { - append(src[i]) - i++ - } - } - - // `inline code` - src.startsWith("`", i) -> { - val end = src.indexOf('`', i + 1) - if (end != -1) { - val raw = src.substring(i + 1, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - background = textColor.copy(alpha = 0.08f), - ), - ) { - append(toAppend) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // bare URL - URL_PATTERN.matchAt(src, i) != null -> { - val match = URL_PATTERN.matchAt(src, i)!! - val url = match.value - val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url - pushLink(LinkAnnotation.Url(url)) - withStyle( - SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline, - ), - ) { - append(urlToAppend) - } - pop() - i = match.range.last + 1 - } - - // Plain text / words: when inside RTL text, wrap sequences of Latin/LTR words in LTR isolate - // so an English word at the start/middle of an Arabic line doesn't flip the layout direction. - isRtl && Character.isLetterOrDigit(src.codePointAt(i)) && - Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> { - var end = i + Character.charCount(src.codePointAt(i)) - while (end < src.length) { - val cp = src.codePointAt(end) - val dir = Character.getDirectionality(cp) - if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT || - dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER || - dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR || - dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR || - ( - dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length && - Character.getDirectionality(src.codePointAt(end + 1)) == - Character.DIRECTIONALITY_LEFT_TO_RIGHT - ) - ) { - end += Character.charCount(cp) - } else { - break - } - } - val ltrSnippet = src.substring(i, end) - append(BidiUtils.wrapLtrIsolate(ltrSnippet)) - i = end - } - - // search highlight - searchQuery.isNotEmpty() && - src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> { - withStyle( - SpanStyle( - background = searchHighlightColor.first, - color = searchHighlightColor.second, - ), - ) { - append(src.substring(i, i + searchQuery.length)) - } - i += searchQuery.length - } - - else -> { - append(src[i]) - i++ - } - } - } - } -} - -private fun resolveImageUrl(uri: String): String { - val trimmed = uri.trim() - if (trimmed.isBlank()) return uri - if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:image/")) { - return trimmed - } - val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("") - val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty() - val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed) - if (mediaUrl != null) return mediaUrl - - if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) { - val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed" - val sep = if (cleanPath.contains("?")) "&" else "?" - return if (token.isNotBlank() && !cleanPath.contains("token=")) { - "${baseUrl.trimEnd('/')}$cleanPath${sep}token=$token" - } else { - "${baseUrl.trimEnd('/')}$cleanPath" - } - } - return uri -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt new file mode 100644 index 00000000..2aed606e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt @@ -0,0 +1,342 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.theme.SearchHighlightColors +import com.m57.hermescontrol.util.BidiUtils + +object MarkdownInlineStyler { + private val URL_PATTERN = + Regex( + """https?://[^\s<>'"\)\],;]+(? + when (segment) { + is InlineMathSegment.Math -> segment.latex + is InlineMathSegment.Text -> segment.value + } + }, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, + ) + + fun parseInlineSource( + text: String, + textColor: Color, + searchQuery: String, + isCurrentMatch: Boolean, + linkColor: Color, + highlights: SearchHighlightColors, + isRtl: Boolean = BidiUtils.isRtlText(text), + ): AnnotatedString { + val searchHighlightColor = + if (isCurrentMatch) { + highlights.currentSearchBackground to highlights.currentSearchForeground + } else { + highlights.searchBackground to highlights.searchForeground + } + + return buildAnnotatedString { + var i = 0 + val src = text + while (i < src.length) { + when { + // ***bold italic*** + src.startsWith("***", i) -> { + val end = src.indexOf("***", i + 3) + if (end != -1) { + val raw = src.substring(i + 3, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { + append(toAppend) + } + i = end + 3 + } else { + append(src[i]) + i++ + } + } + + // **bold** + src.startsWith("**", i) -> { + val end = src.indexOf("**", i + 2) + if (end != -1) { + val raw = src.substring(i + 2, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(toAppend) + } + i = end + 2 + } else { + append(src[i]) + i++ + } + } + + // ~~strike~~ + src.startsWith("~~", i) -> { + val end = src.indexOf("~~", i + 2) + if (end != -1) { + val raw = src.substring(i + 2, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + append(toAppend) + } + i = end + 2 + } else { + append(src[i]) + i++ + } + } + + // *italic* + src.startsWith("*", i) -> { + val end = src.indexOf('*', i + 1) + if (end != -1 && end > i + 1) { + val raw = src.substring(i + 1, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(toAppend) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // ==highlight== + src.startsWith("==", i) -> { + val end = src.indexOf("==", i + 2) + if (end != -1) { + val raw = src.substring(i + 2, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(background = highlights.markupBackground)) { + append(toAppend) + } + i = end + 2 + } else { + append(src[i]) + i++ + } + } + + // ^superscript^ + src.startsWith("^", i) -> { + val end = src.indexOf('^', i + 1) + if (end != -1 && end > i + 1) { + withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { + append(src.substring(i + 1, end)) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // ~subscript~ (single tilde; ~~ handled above) + src.startsWith("~", i) -> { + val end = src.indexOf('~', i + 1) + if (end != -1 && end > i + 1) { + withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { + append(src.substring(i + 1, end)) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // key + src.startsWith("", i) -> { + val end = src.indexOf("", i) + if (end != -1) { + val raw = src.substring(i + 5, end) + val toAppend = if (isRtr(isRtl)) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = textColor.copy(alpha = 0.12f), + ), + ) { + append(toAppend) + } + i = end + 6 + } else { + append(src[i]) + i++ + } + } + + // [^ref] footnote marker -> superscript + src.startsWith("[^", i) -> { + val close = src.indexOf(']', i) + if (close != -1) { + val id = src.substring(i + 2, close) + withStyle( + SpanStyle( + baselineShift = BaselineShift.Superscript, + color = linkColor, + fontWeight = FontWeight.Bold, + ), + ) { + append("[$id]") + } + i = close + 1 + } else { + append(src[i]) + i++ + } + } + + // [text](url) + src.startsWith("[", i) -> { + val close = src.indexOf(']', i) + if (close != -1 && close + 1 < src.length && src[close + 1] == '(') { + val urlEnd = src.indexOf(')', close + 2) + if (urlEnd != -1) { + val label = src.substring(i + 1, close) + val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label + val url = src.substring(close + 2, urlEnd) + pushLink(LinkAnnotation.Url(url)) + withStyle( + SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) { + append(labelToAppend) + } + pop() + i = urlEnd + 1 + } else { + append(src[i]) + i++ + } + } else { + append(src[i]) + i++ + } + } + + // `inline code` + src.startsWith("`", i) -> { + val end = src.indexOf('`', i + 1) + if (end != -1) { + val raw = src.substring(i + 1, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + background = textColor.copy(alpha = 0.08f), + ), + ) { + append(toAppend) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // bare URL + URL_PATTERN.matchAt(src, i) != null -> { + val match = URL_PATTERN.matchAt(src, i)!! + val url = match.value + val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url + pushLink(LinkAnnotation.Url(url)) + withStyle( + SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) { + append(urlToAppend) + } + pop() + i = match.range.last + 1 + } + + // Plain text / words in RTL + isRtl && Character.isLetterOrDigit(src.codePointAt(i)) && + Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> { + var end = i + Character.charCount(src.codePointAt(i)) + while (end < src.length) { + val cp = src.codePointAt(end) + val dir = Character.getDirectionality(cp) + if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR || + ( + dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length && + Character.getDirectionality(src.codePointAt(end + 1)) == + Character.DIRECTIONALITY_LEFT_TO_RIGHT + ) + ) { + end += Character.charCount(cp) + } else { + break + } + } + val ltrSnippet = src.substring(i, end) + append(BidiUtils.wrapLtrIsolate(ltrSnippet)) + i = end + } + + // search highlight + searchQuery.isNotEmpty() && + src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> { + withStyle( + SpanStyle( + background = searchHighlightColor.first, + color = searchHighlightColor.second, + ), + ) { + append(src.substring(i, i + searchQuery.length)) + } + i += searchQuery.length + } + + else -> { + append(src[i]) + i++ + } + } + } + } + } + + private fun isRtr(isRtl: Boolean): Boolean = isRtl +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt new file mode 100644 index 00000000..167ca1e1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt @@ -0,0 +1,113 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.LayoutDirection +import com.hrm.latex.renderer.measure.LatexMeasurerState +import com.hrm.latex.renderer.model.LatexConfig +import com.hrm.latex.renderer.model.LatexTheme +import com.m57.hermescontrol.theme.SearchHighlightColors +import com.m57.hermescontrol.util.BidiUtils + +@Composable +fun MarkdownInlineText( + text: String, + textColor: Color, + latexMeasurer: LatexMeasurerState, + style: TextStyle, + searchQuery: String, + isCurrentMatch: Boolean, + linkColor: Color, + highlights: SearchHighlightColors, + modifier: Modifier = Modifier, +) { + val isRtl = remember(text) { BidiUtils.isRtlText(text) } + val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + val resolvedStyle = + style.copy( + textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr, + ) + val processedText = + remember(text, isRtl) { + if (isRtl) BidiUtils.anchorTrailingRtl(text) else text + } + + CompositionLocalProvider(LocalLayoutDirection provides direction) { + val segments = remember(processedText) { splitInlineMath(processedText) } + if (segments.none { it is InlineMathSegment.Math }) { + Text( + text = + remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) { + MarkdownInlineStyler.parseInlineSource( + text = processedText, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, + ) + }, + color = textColor, + style = resolvedStyle, + modifier = modifier, + ) + return@CompositionLocalProvider + } + + val config = + LatexConfig( + fontSize = style.fontSize, + theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent), + accessibilityEnabled = true, + ) + val inlineContent = mutableMapOf() + segments.forEachIndexed { index, segment -> + if (segment is InlineMathSegment.Math) { + latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it } + } + } + val annotated = + buildAnnotatedString { + segments.forEachIndexed { index, segment -> + when (segment) { + is InlineMathSegment.Math -> { + val id = "latex-$index" + if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex) + } + + is InlineMathSegment.Text -> { + append( + MarkdownInlineStyler.parseInlineSource( + text = segment.value, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, + ), + ) + } + } + } + } + Text( + text = annotated, + inlineContent = inlineContent, + color = textColor, + style = resolvedStyle, + modifier = modifier, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownMediaResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownMediaResolver.kt new file mode 100644 index 00000000..13d8bda6 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownMediaResolver.kt @@ -0,0 +1,29 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.GatewayFileClient + +object MarkdownMediaResolver { + fun resolveImageUrl(uri: String): String { + val trimmed = uri.trim() + if (trimmed.isBlank()) return uri + if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:image/")) { + return trimmed + } + val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("") + val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty() + val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed) + if (mediaUrl != null) return mediaUrl + + if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) { + val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed" + val sep = if (cleanPath.contains("?")) "&" else "?" + return if (token.isNotBlank() && !cleanPath.contains("token=")) { + "${baseUrl.trimEnd('/')}$cleanPath${sep}token=$token" + } else { + "${baseUrl.trimEnd('/')}$cleanPath" + } + } + return uri + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt new file mode 100644 index 00000000..9d6ef28c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt @@ -0,0 +1,103 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.util.BidiUtils + +private val TABLE_COL_WIDTH = 140.dp + +@Composable +fun MarkdownTable( + block: MdBlock.Table, + textColor: Color, + modifier: Modifier = Modifier, +) { + val isRtl = + remember(block) { + block.header.any { BidiUtils.isRtlText(it) } || + block.rows.any { row -> row.any { BidiUtils.isRtlText(it) } } + } + val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + val headerBg = textColor.copy(alpha = 0.08f) + val alignments = block.alignments + CompositionLocalProvider(LocalLayoutDirection provides tableDirection) { + Column( + modifier = + modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = 4.dp), + ) { + // Header row + Row(modifier = Modifier.background(headerBg)) { + block.header.forEachIndexed { idx, cell -> + val cellRtl = BidiUtils.isRtlText(cell) + Text( + text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, + textAlign = tableTextAlign(alignments.getOrNull(idx)), + color = textColor, + style = + MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Bold, + textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + modifier = + Modifier + .width(TABLE_COL_WIDTH) + .padding(6.dp), + ) + } + } + HorizontalDivider(color = textColor.copy(alpha = 0.25f)) + // Body rows + block.rows.forEach { row -> + Row { + row.forEachIndexed { idx, cell -> + val cellRtl = BidiUtils.isRtlText(cell) + Text( + text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, + textAlign = tableTextAlign(alignments.getOrNull(idx)), + color = textColor, + style = + MaterialTheme.typography.bodySmall.copy( + textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + modifier = + Modifier + .width(TABLE_COL_WIDTH) + .padding(6.dp), + ) + } + } + HorizontalDivider(color = textColor.copy(alpha = 0.1f)) + } + } + } +} + +private fun tableTextAlign(align: TableAlign?): TextAlign? = + when (align) { + TableAlign.CENTER -> TextAlign.Center + TableAlign.RIGHT -> TextAlign.End + else -> null + } From 02106c3781b0469c3db570a34c0068774b032922 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 20:13:00 +0300 Subject: [PATCH 28/42] refactor(core): execute Phase 3 state and delegate extractions - Extract ChatClarifyDelegate from ChatViewModel for clarify prompt flow. - Harden GroupChatViewModel maps with ConcurrentHashMap. - Add failure circuit breaker to MCP OAuth polling loop. --- .../ui/bots/group/GroupChatViewModel.kt | 13 +- .../ui/chat/ChatClarifyDelegate.kt | 123 ++++++++++++++++++ .../hermescontrol/ui/chat/ChatViewModel.kt | 112 ++-------------- .../ui/mcp/McpServersViewModel.kt | 12 +- 4 files changed, 153 insertions(+), 107 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index 65bc2243..eb10325e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -33,6 +33,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement import java.util.UUID +import java.util.concurrent.ConcurrentHashMap data class GroupChatUiState( val groupName: String = "", @@ -74,22 +75,22 @@ class GroupChatViewModel( private var activeEpoch: String = "" // Map of bot.name -> seen count of non-system messages - private val memberWatermarks = mutableMapOf() + private val memberWatermarks = ConcurrentHashMap() // Map of bot.name -> active session info in this group - private val memberSessions = mutableMapOf() + private val memberSessions = ConcurrentHashMap() // In-flight turn completion deferreds keyed by session_id - private val inFlightTurns = mutableMapOf>() + private val inFlightTurns = ConcurrentHashMap>() // Map of session_id -> active streaming message ID - private val activeStreamMsgId = mutableMapOf() + private val activeStreamMsgId = ConcurrentHashMap() // Map of session_id -> bot profile - private val sessionToBot = mutableMapOf() + private val sessionToBot = ConcurrentHashMap() // Map of session_id -> last activity epoch ms (streaming token, tool call, thinking) - private val sessionLastActivity = mutableMapOf() + private val sessionLastActivity = ConcurrentHashMap() init { if (groupName.isNotBlank()) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt new file mode 100644 index 00000000..e24f6b6b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt @@ -0,0 +1,123 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class ChatClarifyDelegate( + private val uiState: MutableStateFlow, + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher, + private val persistMessage: suspend (ChatMessage, String) -> Unit, + private val wsClient: HermesWsClient = HermesWsClient, + private val trackRequest: (String, String) -> Unit = { _, _ -> }, +) { + fun respondToClarify(option: String) { + val clarify = uiState.value.clarifyRequest + // Only use synthesized qid when this is a true batch or legacy with explicit qid. + val qid = + if (clarify != null && + (clarify.questionId != null || clarify.questions.isNotEmpty()) + ) { + clarify.resolvedQuestions.firstOrNull()?.qid ?: clarify.questionId + } else { + null + } + if (qid != null && clarify?.resolvedQuestions?.size == 1) { + respondToClarifyBatch(mapOf(qid to option)) + } else { + respondToClarifyBatch(emptyMap(), singleFallbackAnswer = option) + } + } + + fun respondToClarifyBatch( + answers: Map, + singleFallbackAnswer: String? = null, + ) { + val sessionId = uiState.value.currentSessionId ?: return + val clarify = uiState.value.clarifyRequest + val clarifyId = clarify?.clarifyId + val isBatch = !clarify?.questions.isNullOrEmpty() + val questions = clarify?.resolvedQuestions.orEmpty() + uiState.update { it.copy(clarifyRequest = null) } + + val displayContent = + if (questions.size > 1) { + questions + .mapIndexed { index, q -> + val ans = answers[q.qid]?.trim().orEmpty() + "${index + 1}. ${ans.ifEmpty { "(Skipped)" }}" + }.joinToString("\n") + } else { + val loneAns = answers.values.firstOrNull()?.trim() ?: singleFallbackAnswer?.trim().orEmpty() + loneAns + } + + val userMessage = + ChatMessage( + role = MessageRole.USER, + content = displayContent, + ) + + uiState.update { state -> + state.copy( + messages = state.messages + userMessage, + isAgentTyping = true, + ) + } + + scope.launch(ioDispatcher) { + persistMessage(userMessage, sessionId) + } + + scope.launch(ioDispatcher) { + if (isBatch) { + for (q in questions) { + val ans = answers[q.qid]?.trim().orEmpty() + val params = + mutableMapOf( + "session_id" to sessionId, + "response" to ans, + "answer" to ans, + "question_id" to q.qid, + ) + if (clarifyId != null) { + params["clarify_id"] = clarifyId + params["request_id"] = clarifyId + } + wsClient.send( + method = WsMethods.CLARIFY_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, + ) + } + } else { + // Legacy single: only include question_id when explicitly present. + val qid = clarify?.questionId + val ans = answers.values.firstOrNull() ?: singleFallbackAnswer.orEmpty() + val params = + mutableMapOf( + "session_id" to sessionId, + "response" to ans, + "answer" to ans, + ) + if (clarifyId != null) { + params["clarify_id"] = clarifyId + params["request_id"] = clarifyId + } + if (qid != null) { + params["question_id"] = qid + } + wsClient.send( + method = WsMethods.CLARIFY_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 551faf95..7282b615 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -435,6 +435,16 @@ class ChatViewModel( addSystemMessage = { text -> addSystemMessage(text) }, ) + private val clarifyDelegate = + ChatClarifyDelegate( + uiState = _uiState, + scope = viewModelScope, + ioDispatcher = ioDispatcher, + persistMessage = { msg, sid -> repo.persistMessage(msg, sid) }, + wsClient = wsClient, + trackRequest = { id, method -> trackRequest(id, method) }, + ) + private val streamingController = ChatStreamingController( scope = viewModelScope, @@ -3172,110 +3182,12 @@ class ChatViewModel( } } - fun respondToClarify(option: String) { - val clarify = _uiState.value.clarifyRequest - // Only use synthesized qid when this is a true batch or legacy with explicit qid. - val qid = - if (clarify != null && - (clarify.questionId != null || clarify.questions.isNotEmpty()) - ) { - clarify.resolvedQuestions.firstOrNull()?.qid ?: clarify.questionId - } else { - null - } - if (qid != null && clarify?.resolvedQuestions?.size == 1) { - respondToClarifyBatch(mapOf(qid to option)) - } else { - respondToClarifyBatch(emptyMap(), singleFallbackAnswer = option) - } - } + fun respondToClarify(option: String) = clarifyDelegate.respondToClarify(option) fun respondToClarifyBatch( answers: Map, singleFallbackAnswer: String? = null, - ) { - val sessionId = _uiState.value.currentSessionId ?: return - val clarify = _uiState.value.clarifyRequest - val clarifyId = clarify?.clarifyId - val isBatch = !clarify?.questions.isNullOrEmpty() - val questions = clarify?.resolvedQuestions.orEmpty() - _uiState.update { it.copy(clarifyRequest = null) } - - val displayContent = - if (questions.size > 1) { - questions - .mapIndexed { index, q -> - val ans = answers[q.qid]?.trim().orEmpty() - "${index + 1}. ${ans.ifEmpty { "(Skipped)" }}" - }.joinToString("\n") - } else { - val loneAns = answers.values.firstOrNull()?.trim() ?: singleFallbackAnswer?.trim().orEmpty() - loneAns - } - - val userMessage = - ChatMessage( - role = MessageRole.USER, - content = displayContent, - ) - - _uiState.update { state -> - state.copy( - messages = state.messages + userMessage, - isAgentTyping = true, - ) - } - - viewModelScope.launch(ioDispatcher) { - repo.persistMessage(userMessage, sessionId) - } - - viewModelScope.launch(ioDispatcher) { - if (isBatch) { - for (q in questions) { - val ans = answers[q.qid]?.trim().orEmpty() - val params = - mutableMapOf( - "session_id" to sessionId, - "response" to ans, - "answer" to ans, - "question_id" to q.qid, - ) - if (clarifyId != null) { - params["clarify_id"] = clarifyId - params["request_id"] = clarifyId - } - wsClient.send( - method = WsMethods.CLARIFY_RESPOND, - params = params, - onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, - ) - } - } else { - // Legacy single: only include question_id when explicitly present. - val qid = clarify?.questionId - val ans = answers.values.firstOrNull() ?: singleFallbackAnswer.orEmpty() - val params = - mutableMapOf( - "session_id" to sessionId, - "response" to ans, - "answer" to ans, - ) - if (clarifyId != null) { - params["clarify_id"] = clarifyId - params["request_id"] = clarifyId - } - if (qid != null) { - params["question_id"] = qid - } - wsClient.send( - method = WsMethods.CLARIFY_RESPOND, - params = params, - onSent = { id -> trackRequest(id, WsMethods.CLARIFY_RESPOND) }, - ) - } - } - } + ) = clarifyDelegate.respondToClarifyBatch(answers, singleFallbackAnswer) fun clearError() { _uiState.update { it.copy(errorMessage = null) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 4e9ead31..3c64bacd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -585,6 +585,7 @@ class McpServersViewModel : oauthPollJob = viewModelScope.launch { var polling = true + var consecutiveFailures = 0 while (polling) { kotlinx.coroutines.delay(2000) val result = @@ -629,7 +630,16 @@ class McpServersViewModel : } is NetworkResult.Failure -> { - // Keep polling or stop after too many failures? Let's just log/toast n retry a few times + consecutiveFailures++ + if (consecutiveFailures >= 10) { + polling = false + _uiState.update { + it.copy( + activeOAuthFlow = null, + toastMessage = "OAuth polling timed out after repeated network failures", + ) + } + } } } } From 1cdc0dbfc042b70e9a355aeed69bacdd957e3796 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:19:13 +0300 Subject: [PATCH 29/42] fix(ws): gateway liveness, replay correctness, and streaming flush (#1085) --- .../hermescontrol/data/ws/HermesWsClient.kt | 192 ++++++++++++------ .../com/m57/hermescontrol/data/ws/WsEvent.kt | 7 + .../m57/hermescontrol/data/ws/WsMethods.kt | 3 + .../ui/chat/ChatStreamingController.kt | 157 ++++++++++---- .../hermescontrol/ui/chat/ChatViewModel.kt | 13 +- .../ui/chat/ChatWsEventReducer.kt | 4 + .../data/ws/HermesWsClientTest.kt | 95 ++++++++- .../ui/chat/ChatStreamingControllerTest.kt | 19 ++ 8 files changed, 372 insertions(+), 118 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 80dde78e..cca84223 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.currentCoroutineContext @@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonElement @@ -62,34 +64,48 @@ enum class ConnectionStatus { object HermesWsClient { private const val TAG = "HermesWsClient" - // ── Backoff settings ───────────────────────────────────────────────── + // ── Monotonic time helper (immune to NTP and wall-clock jumps) ───────── + private fun monotonicTimeMs(): Long = System.nanoTime() / 1_000_000L + + // ── Backoff settings (desktop parity: reconnect-backoff.ts full-jitter) ─ private var initialBackoffMs = 1_000L + private const val BACKOFF_BASE_MS = 300L + private const val BACKOFF_CAP_MS = 15_000L private const val MAX_BACKOFF_MS = 30_000L private const val BACKOFF_MULTIPLIER = 2.0 + private var testBackoffOverrideMs: Long? = null /** * Test hook: the initial reconnect backoff, overridable so reconnect * tests are deterministic instead of racing real time (CI runners - * routinely exceed fixed latch windows). Production keeps 1s. + * routinely exceed fixed latch windows). Production keeps full-jitter backoff. */ @VisibleForTesting internal fun setReconnectBackoffForTest(initialMillis: Long) { initialBackoffMs = initialMillis currentBackoff = initialMillis + testBackoffOverrideMs = initialMillis + } + + private fun nextBackoffDelayMs(): Long { + val override = testBackoffOverrideMs + if (override != null) return override + val ceiling = currentBackoff + if (ceiling <= 0L) return 0L + return (Math.random() * ceiling).toLong().coerceAtLeast(BACKOFF_BASE_MS).coerceAtMost(ceiling) } private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024 private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L /** - * No inbound frame for this long means the link is dead even though the - * TCP session may still look open (silent NAT/VPN drop). OkHttp pings - * every 30s (OkHttpProvider.websocket) and any inbound frame refreshes - * [lastPongTimestamp], so a healthy link never exceeds ~30s of silence. - * 90s tolerates exactly one missed ping cycle before acting. + * Liveness thresholds aligned with reference clients (desktop/web): + * Ping every 15s using gateway.ping; 45s inbound silence deadline. */ - private const val STALE_THRESHOLD_MS = 90_000L + private const val HEARTBEAT_INTERVAL_MS = 15_000L + private const val STALE_THRESHOLD_MS = 45_000L + private const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L // ── Internal state (all access through synchronized / atomic) ──────── @@ -150,7 +166,7 @@ object HermesWsClient { private set val isHealthy: Boolean - get() = isConnected && (System.currentTimeMillis() - lastPongTimestamp < 60_000L) + get() = isConnected && (monotonicTimeMs() - lastPongTimestamp < STALE_THRESHOLD_MS) // ── Observable latency tracking (issue #1017) ───────────────────────── private val _lastLatencyMs = MutableStateFlow(null) @@ -162,14 +178,14 @@ object HermesWsClient { private fun startHealthTracking() { healthJob?.cancel() - lastPongTimestamp = System.currentTimeMillis() + lastPongTimestamp = monotonicTimeMs() healthJob = wsScope.launch { while (connected.get()) { - delay(30_000L) + delay(HEARTBEAT_INTERVAL_MS) if (connected.get()) { try { - ping(timeoutMs = 10_000L) + ping(timeoutMs = LIVENESS_PROBE_TIMEOUT_MS) } catch (e: Exception) { Log.w(TAG, "Health check ping failed: ${e.message}") } @@ -181,14 +197,15 @@ object HermesWsClient { /** * Ultra-lightweight JSON-RPC liveness check and RTT measurement (issue #1017). - * Bypasses agent queues on the backend; measures round-trip time in milliseconds. + * Uses [WsMethods.GATEWAY_PING] which is answered inline in the WebSocket read + * loop (ws.py:350) and never queues behind worker dispatch pools. * Updates [lastLatencyMs] and [lastPongTimestamp]. */ - suspend fun ping(timeoutMs: Long = 5_000L): Long { - val start = System.currentTimeMillis() - request(WsMethods.PING, emptyMap(), timeoutMs = timeoutMs).await() - val latency = (System.currentTimeMillis() - start).coerceAtLeast(0L) - lastPongTimestamp = System.currentTimeMillis() + suspend fun ping(timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS): Long { + val start = monotonicTimeMs() + request(WsMethods.GATEWAY_PING, emptyMap(), timeoutMs = timeoutMs).await() + val latency = (monotonicTimeMs() - start).coerceAtLeast(0L) + lastPongTimestamp = monotonicTimeMs() _lastLatencyMs.value = latency return latency } @@ -207,7 +224,7 @@ object HermesWsClient { */ private fun runHealthCheckPass(): Boolean { if (!connected.get()) return false - val staleMs = System.currentTimeMillis() - lastPongTimestamp + val staleMs = monotonicTimeMs() - lastPongTimestamp if (staleMs <= STALE_THRESHOLD_MS) return true Log.w(TAG, "WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect") synchronized(outboundLock) { @@ -220,11 +237,11 @@ object HermesWsClient { /** * Test hook: backdate liveness past the staleness threshold and force one * synchronous watchdog pass, so reconnect tests are deterministic instead - * of racing the real 30s/90s cadence. + * of racing the real 15s/45s cadence. */ @VisibleForTesting internal fun forceHealthCheckForTest(staleMillis: Long) { - lastPongTimestamp = System.currentTimeMillis() - staleMillis + lastPongTimestamp = monotonicTimeMs() - staleMillis runHealthCheckPass() } @@ -238,7 +255,7 @@ object HermesWsClient { private val parsedEvents = MutableSharedFlow( - extraBufferCapacity = 512, + extraBufferCapacity = 2048, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) @@ -346,9 +363,34 @@ object HermesWsClient { @VisibleForTesting val isConnected: Boolean get() = connected.get() + private var foregroundProbeJob: Job? = null + fun setAppForeground(foreground: Boolean) { appInForeground.set(foreground) - if (!foreground) disconnectIfIdleInBackground() + if (!foreground) { + disconnectIfIdleInBackground() + return + } + probeLivenessOnWake() + } + + private fun probeLivenessOnWake(deferredOnce: Boolean = false) { + if (!connected.get()) return + foregroundProbeJob?.cancel() + foregroundProbeJob = + wsScope.launch { + val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess + if (alive) return@launch + if (pendingReply && !deferredOnce) { + delay(3_000L) + probeLivenessOnWake(deferredOnce = true) + return@launch + } + Log.w(TAG, "Foreground liveness probe failed — cancelling socket") + synchronized(outboundLock) { + if (connected.get()) webSocket?.cancel() + } + } } fun acquireExternalActivityConnectionLease() { @@ -629,6 +671,8 @@ object HermesWsClient { reconnectJob = null outboundDrainJob?.cancel() outboundDrainJob = null + foregroundProbeJob?.cancel() + foregroundProbeJob = null stopHealthTracking() webSocket?.close(1000, "Client closed") webSocket = null @@ -890,21 +934,23 @@ object HermesWsClient { // ── Sequence tracking & replay on reconnect (issue #1016) ───────────── private fun triggerReplay() { + // Arm the hold SYNCHRONOUSLY (still under onOpen's outboundLock) so a + // live frame racing the replay coroutine is parked instead of advancing + // lastSeenSeq past events the replay would then skip-drop (G2). if (lastSeenSeq.isEmpty() || replayInFlight) return - replayJob?.cancel() - replayJob = - wsScope.launch { - fetchReplay() - } - } - - private suspend fun fetchReplay() { - if (replayInFlight || lastSeenSeq.isEmpty()) return replayInFlight = true val sessionsToReplay = lastSeenSeq.keys().toList() for (sid in sessionsToReplay) { replayHold[sid] = Collections.synchronizedList(mutableListOf()) } + replayJob?.cancel() + replayJob = + wsScope.launch { + fetchReplay(sessionsToReplay) + } + } + + private suspend fun fetchReplay(sessionsToReplay: List = lastSeenSeq.keys().toList()) { try { for (sid in sessionsToReplay) { val lastSeen = lastSeenSeq[sid] ?: continue @@ -926,13 +972,21 @@ object HermesWsClient { } if (resultMap != null) { val epoch = resultMap["epoch"] as? String - if (!epoch.isNullOrEmpty()) { - if (replayEpoch != null && replayEpoch != epoch) { - replayEpoch = epoch - lastSeenSeq.clear() - continue - } - replayEpoch = epoch + val epochChanged = + !epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch + val truncated = resultMap["truncated"] == true + val latestSeq = (resultMap["latest_seq"] as? Number)?.toInt() + + if (epochChanged) replayEpoch = epoch + + if (epochChanged || truncated) { + // Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted). + // Partial replay would silently hole the transcript — fast-forward watermark + // and request full transcript resync. + if (epochChanged) lastSeenSeq.clear() + if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq + parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid)) + continue } val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance>() if (eventsList != null) { @@ -962,25 +1016,28 @@ object HermesWsClient { } } catch (e: Exception) { Log.w(TAG, "Replay failed for session $sid: ${e.message}") + parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid)) } } } finally { - for (sid in sessionsToReplay) { - val held = replayHold.remove(sid) - if (held != null) { - synchronized(held) { - for ((heldSeq, heldEvent) in held) { - if (heldSeq != null) { - val prev = lastSeenSeq[sid] ?: 0 - if (heldSeq <= prev) continue - lastSeenSeq[sid] = heldSeq + withContext(NonCancellable) { + for (sid in sessionsToReplay) { + val held = replayHold.remove(sid) + if (held != null) { + synchronized(held) { + for ((heldSeq, heldEvent) in held) { + if (heldSeq != null) { + val prev = lastSeenSeq[sid] ?: 0 + if (heldSeq <= prev) continue + lastSeenSeq[sid] = heldSeq + } + parsedEvents.tryEmit(heldEvent) } - parsedEvents.tryEmit(heldEvent) } } } + replayInFlight = false } - replayInFlight = false } } @@ -1065,7 +1122,7 @@ object HermesWsClient { _connectionStatus.value = ConnectionStatus.NO_NETWORK return } - val reconnectDelay = currentBackoff + val reconnectDelay = nextBackoffDelayMs() currentBackoff = (currentBackoff * BACKOFF_MULTIPLIER) .toLong() @@ -1092,6 +1149,14 @@ object HermesWsClient { } } + private fun isTerminalAuthClose( + code: Int, + reason: String, + ): Boolean = + code == 4401 || code == 4403 || + reason.contains("unauthorized", ignoreCase = true) || + reason.startsWith("auth:", ignoreCase = true) + // ── Listener ───────────────────────────────────────────────────────── private class WsListenerImpl( @@ -1145,7 +1210,7 @@ object HermesWsClient { ) { if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return if (BuildConfig.DEBUG) Log.d(TAG, "← $text") - lastPongTimestamp = System.currentTimeMillis() + lastPongTimestamp = monotonicTimeMs() // Resolve any in-flight `request()` awaiting this RPC result/error // (issue #526) before fanning the parsed event out to collectors. val event = @@ -1173,6 +1238,7 @@ object HermesWsClient { if (!sid.isNullOrBlank() && seq != null) { val prev = lastSeenSeq[sid] ?: 0 if (seq <= prev) { + // Frame is already at or behind watermark — drop duplicate return } val parsed = EventParser.parse(rpc, text) @@ -1219,17 +1285,17 @@ object HermesWsClient { pendingPromptSubmits.isEmpty() ) { pendingReply = false + disconnectIfIdleInBackground() } } removeQueuedMessage(event.id) resolvePending(event.id, null, event.error) - disconnectIfIdleInBackground() } else -> {} } // tryEmit on a DROP_OLDEST flow only returns false when the - // buffer is full AND no subscriber is draining; with extraBuffer=512 + // buffer is full AND no subscriber is draining; with extraBuffer=2048 // and always-on init collectors this is unreachable in practice. parsedEvents.tryEmit(event) } @@ -1247,10 +1313,7 @@ object HermesWsClient { closingSocket = webSocket // Do NOT log [reason] — it may carry server-side context. Log.d(TAG, "WebSocket closing: $code") - if (code == 4001 || code == 4401 || - reason.contains("unauthorized", ignoreCase = true) || - reason.startsWith("auth:", ignoreCase = true) - ) { + if (isTerminalAuthClose(code, reason)) { _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED } webSocket.close(code, reason) @@ -1275,10 +1338,7 @@ object HermesWsClient { connected.set(false) ActiveSessionHolder.clear() stopHealthTracking() - if (code == 4001 || code == 4401 || - reason.contains("unauthorized", ignoreCase = true) || - reason.startsWith("auth:", ignoreCase = true) - ) { + if (isTerminalAuthClose(code, reason)) { _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED } else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) { _connectionStatus.value = ConnectionStatus.RECONNECTING @@ -1307,9 +1367,11 @@ object HermesWsClient { ActiveSessionHolder.clear() stopHealthTracking() val code = response?.code ?: 0 - if (code == 401 || t.message?.contains( - "401", - ) == true || t.message?.contains("unauthorized", ignoreCase = true) == true + if (code == 401 || code == 4401 || code == 4403 || + t.message?.contains("401") == true || + t.message?.contains("4401") == true || + t.message?.contains("4403") == true || + t.message?.contains("unauthorized", ignoreCase = true) == true ) { _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED } else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 7f1cff0f..21d61b6a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -355,6 +355,13 @@ sealed class WsEvent { val kind: String = "", ) : WsEvent() + // ── Replay resync (internal) ────────────────────────────────────────── + + /** Internal: replay could not cover the reconnect gap (truncated or epoch change) — UI must refetch history. */ + data class TranscriptResyncRequired( + val sessionId: String, + ) : WsEvent() + // ── Fallback ───────────────────────────────────────────────────────── data class Unknown( diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index c8e00fb4..e2d8a1da 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -48,6 +48,9 @@ object WsMethods { /** Ultra-lightweight liveness check and round-trip latency measurement. */ const val PING = "ping" + /** Inline WebSocket read-loop ping, bypasses server thread pool dispatch. */ + const val GATEWAY_PING = "gateway.ping" + // ── Commands catalog ────────────────────────────────────────────────── const val COMMANDS_CATALOG = "commands.catalog" const val COMMAND_DISPATCH = "command.dispatch" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt index 0d06ccc5..9652cf5f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt @@ -2,8 +2,11 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.ws.WsEvent import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch /** * Holds chat message-streaming state and logic, extracted from [ChatViewModel] @@ -27,14 +30,23 @@ class ChatStreamingController( private val isCurrentSession: (String?) -> Boolean, private val isTestEnvironment: () -> Boolean, ) { + private companion object { + const val FLUSH_INTERVAL_MS = 33L + } + private val streamingBuffer = java.lang.StringBuilder() private var lastFlushMs = 0L + private var trailingMessageFlushJob: Job? = null private val thinkingBuffer = java.lang.StringBuilder() private var lastThinkingFlushMs = 0L + private var trailingThinkingFlushJob: Job? = null private val reasoningBuffer = java.lang.StringBuilder() private var lastReasoningFlushMs = 0L + private var trailingReasoningFlushJob: Job? = null + + private fun nowMs(): Long = System.nanoTime() / 1_000_000L /** * Resets all streaming buffers. Centralizes the clear logic that was @@ -42,6 +54,9 @@ class ChatStreamingController( * ToolStart, ClarifyRequest, session switches, and interrupt handling. */ fun resetStreaming() { + trailingMessageFlushJob?.cancel() + trailingThinkingFlushJob?.cancel() + trailingReasoningFlushJob?.cancel() flushReasoning() streamingBuffer.clear() thinkingBuffer.clear() @@ -73,6 +88,9 @@ class ChatStreamingController( * buffer-only clear is safe and prevents stale deltas from re-flushing. */ fun clearStreamingBuffers() { + trailingMessageFlushJob?.cancel() + trailingThinkingFlushJob?.cancel() + trailingReasoningFlushJob?.cancel() streamingBuffer.clear() thinkingBuffer.clear() reasoningBuffer.clear() @@ -94,6 +112,7 @@ class ChatStreamingController( * synchronously so the finalized message carries the latest reasoning. */ fun flushPendingReasoning() { + trailingReasoningFlushJob?.cancel() flushReasoning() } @@ -111,8 +130,10 @@ class ChatStreamingController( * tokens synchronously first makes the sealed orphan complete. */ fun flushPendingTokens() { + trailingMessageFlushJob?.cancel() val currentContent = streamingBuffer.toString() if (currentContent.isEmpty()) return + lastFlushMs = nowMs() streamingState.update { state -> val current = state.streamingMessage ?: return@update state state.copy( @@ -130,63 +151,97 @@ class ChatStreamingController( if (!isCurrentSession(event.sessionId)) return streamingBuffer.append(event.token) - val now = System.currentTimeMillis() + val now = nowMs() // Always flush in tests, or if enough time has passed - val shouldFlush = (now - lastFlushMs >= 33L) || lastFlushMs == 0L || isTestEnvironment() + val shouldFlush = (now - lastFlushMs >= FLUSH_INTERVAL_MS) || lastFlushMs == 0L || isTestEnvironment() if (shouldFlush) { - val currentContent = streamingBuffer.toString() - lastFlushMs = now - streamingState.update { state -> - val current = state.streamingMessage - if (current != null) { - val currentReasoning = - if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText - state.copy( - streamingMessage = - current.copy( - content = currentContent, - reasoningText = currentReasoning, - ), - isThinking = false, - ) - } else { - // Fallback: no MessageStart was received — create one now - val msg = - ChatMessage( - role = MessageRole.ASSISTANT, - content = currentContent, - reasoningText = state.reasoningText, - isStreaming = true, - ) - state.copy( - streamingMessage = msg, - isThinking = false, - ) - } - } - uiState.update { it.copy(isAgentTyping = true) } + trailingMessageFlushJob?.cancel() + flushTokens(now) + } else { + scheduleTrailingMessageFlush() } } + private fun scheduleTrailingMessageFlush() { + if (trailingMessageFlushJob?.isActive == true) return + trailingMessageFlushJob = + scope.launch { + delay(FLUSH_INTERVAL_MS) + flushTokens(nowMs()) + } + } + + private fun flushTokens(now: Long) { + val currentContent = streamingBuffer.toString() + if (currentContent.isEmpty()) return + lastFlushMs = now + streamingState.update { state -> + val current = state.streamingMessage + if (current != null) { + val currentReasoning = + if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText + state.copy( + streamingMessage = + current.copy( + content = currentContent, + reasoningText = currentReasoning, + ), + isThinking = false, + ) + } else { + // Fallback: no MessageStart was received — create one now + val msg = + ChatMessage( + role = MessageRole.ASSISTANT, + content = currentContent, + reasoningText = state.reasoningText, + isStreaming = true, + ) + state.copy( + streamingMessage = msg, + isThinking = false, + ) + } + } + uiState.update { it.copy(isAgentTyping = true) } + } + fun handleThinkingDelta(event: WsEvent.ThinkingDelta) { if (!isCurrentSession(event.sessionId)) return thinkingBuffer.append(event.token) - val now = System.currentTimeMillis() + val now = nowMs() // Always flush in tests, or if enough time has passed val shouldFlush = - (now - lastThinkingFlushMs >= 33L) || lastThinkingFlushMs == 0L || isTestEnvironment() + (now - lastThinkingFlushMs >= FLUSH_INTERVAL_MS) || lastThinkingFlushMs == 0L || isTestEnvironment() if (shouldFlush) { - val currentContent = thinkingBuffer.toString() - lastThinkingFlushMs = now - streamingState.update { state -> - state.copy( - isThinking = true, - thinkingText = currentContent, - ) + trailingThinkingFlushJob?.cancel() + flushThinking(now) + } else { + scheduleTrailingThinkingFlush() + } + } + + private fun scheduleTrailingThinkingFlush() { + if (trailingThinkingFlushJob?.isActive == true) return + trailingThinkingFlushJob = + scope.launch { + delay(FLUSH_INTERVAL_MS) + flushThinking(nowMs()) } + } + + private fun flushThinking(now: Long) { + val currentContent = thinkingBuffer.toString() + if (currentContent.isEmpty()) return + lastThinkingFlushMs = now + streamingState.update { state -> + state.copy( + isThinking = true, + thinkingText = currentContent, + ) } } @@ -194,17 +249,29 @@ class ChatStreamingController( if (!isCurrentSession(event.sessionId)) return reasoningBuffer.append(event.token) - val now = System.currentTimeMillis() + val now = nowMs() // Always flush in tests, or if enough time has passed val shouldFlush = - (now - lastReasoningFlushMs >= 33L) || lastReasoningFlushMs == 0L || isTestEnvironment() + (now - lastReasoningFlushMs >= FLUSH_INTERVAL_MS) || lastReasoningFlushMs == 0L || isTestEnvironment() if (shouldFlush) { + trailingReasoningFlushJob?.cancel() flushReasoning(now) + } else { + scheduleTrailingReasoningFlush() } } - private fun flushReasoning(now: Long = System.currentTimeMillis()) { + private fun scheduleTrailingReasoningFlush() { + if (trailingReasoningFlushJob?.isActive == true) return + trailingReasoningFlushJob = + scope.launch { + delay(FLUSH_INTERVAL_MS) + flushReasoning(nowMs()) + } + } + + private fun flushReasoning(now: Long = nowMs()) { val currentContent = reasoningBuffer.toString() if (currentContent.isEmpty()) return lastReasoningFlushMs = now diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 7282b615..3e24796e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -12,7 +12,6 @@ import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.ModelCapabilities import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel -import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.parseContextBreakdown import com.m57.hermescontrol.data.model.parseUsageSnapshot import com.m57.hermescontrol.data.remote.ApiClient @@ -45,10 +44,6 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonPrimitive -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONObject import java.io.File import java.util.concurrent.ConcurrentHashMap @@ -838,6 +833,14 @@ class ChatViewModel( loadSessions() } + is WsEvent.TranscriptResyncRequired -> { + val current = runtimeSessionId ?: _uiState.value.currentSessionId + if (current != null && (current == event.sessionId || event.sessionId.isEmpty())) { + val storageId = ActiveSessionHolder.resolveStoredSessionId(current) ?: current + loadSessionMessages(storageId, sessionGeneration) + } + } + is WsEvent.ClarifyRequest -> { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index d4f84abe..d8db9c15 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -74,6 +74,7 @@ object ChatWsEventReducer { is WsEvent.ReviewSummary -> event.sessionId is WsEvent.BtwComplete -> event.sessionId is WsEvent.SessionUsage -> event.sessionId + is WsEvent.TranscriptResyncRequired -> event.sessionId else -> null } if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) { @@ -174,6 +175,9 @@ object ChatWsEventReducer { // Change events (issue #784) are consumed by their screens' ViewModels is WsEvent.ChangeEvent -> ReducerResult(state = state, streamingState = streamingState) + + // TranscriptResyncRequired is handled by ChatViewModel directly (refetch history on replay gap) + is WsEvent.TranscriptResyncRequired -> ReducerResult(state = state, streamingState = streamingState) } // ── GatewayReady ────────────────────────────────────────────────── diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt index 7994b464..bfd30a21 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -15,6 +15,8 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -35,6 +37,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.io.IOException +import java.util.Collections import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger @@ -1576,7 +1579,8 @@ class HermesWsClientTest { // Send duplicate seq 1 -> should be dropped ws.send( - """{"method":"event","params":{"type":"message.token","session_id":"s1","seq":1,"payload":{"text":"A-dup"}}}""", + """{"method":"event","params":{"type":"message.token","session_id":"s1","seq":1,""" + + """"payload":{"text":"A-dup"}}}""", ) Thread.sleep(100) assertEquals(1, HermesWsClient.getSeqWatermarks()["s1"]) @@ -1664,7 +1668,9 @@ class HermesWsClientTest { // Extract ID from JSON-RPC request val id = Regex(""""id":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: "1" webSocket.send( - """{"jsonrpc":"2.0","id":"$id","result":{"epoch":"ep1","events":[{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"replayed"}}]}}""", + """{"jsonrpc":"2.0","id":"$id","result":{"epoch":"ep1","events":""" + + """[{"type":"message.token","session_id":"s1","seq":6,""" + + """"payload":{"text":"replayed"}}]}}""", ) requestLatch.countDown() } @@ -1689,9 +1695,92 @@ class HermesWsClientTest { assertEquals(6, HermesWsClient.getSeqWatermarks()["s1"]) } + @Test + fun testTerminalCloseCode4403SetsAuthExpired() { + val socket = mockk(relaxed = true) + val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") + intentionalCloseField.isAccessible = true + (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + + val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") + generationField.isAccessible = true + (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1) + + val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") + val constructor = listenerClass.declaredConstructors.single() + constructor.isAccessible = true + val listener = constructor.newInstance(1) as WebSocketListener + + listener.onClosing(socket, 4403, "forbidden origin") + assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value) + } + + @Test + fun testReplayTruncatedEmitsTranscriptResyncRequired() { + var serverWebSocket: WebSocket? = null + val serverLatch = CountDownLatch(1) + val requestLatch = CountDownLatch(1) + var receivedMethod: String? = null + val receivedEvents = Collections.synchronizedList(mutableListOf()) + + val collectJob = + CoroutineScope(Dispatchers.IO).launch { + HermesWsClient.events.collect { receivedEvents.add(it) } + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverWebSocket = webSocket + serverLatch.countDown() + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + if (text.contains(WsMethods.SESSION_EVENTS_SINCE)) { + receivedMethod = WsMethods.SESSION_EVENTS_SINCE + val id = Regex(""""id":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + + """{"epoch":"ep1","truncated":true,"latest_seq":100,"events":[]}}""", + ) + requestLatch.countDown() + } + } + }, + ), + ) + + HermesWsClient.setSeqWatermark("s1", 5) + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } + } + } + assertTrue(serverLatch.await(5, TimeUnit.SECONDS)) + assertTrue(requestLatch.await(5, TimeUnit.SECONDS)) + assertEquals(WsMethods.SESSION_EVENTS_SINCE, receivedMethod) + + // Wait for replay processing + Thread.sleep(300) + assertEquals(100, HermesWsClient.getSeqWatermarks()["s1"]) + val resyncEvent = receivedEvents.filterIsInstance().firstOrNull() + assertNotNull(resyncEvent) + assertEquals("s1", resyncEvent?.sessionId) + collectJob.cancel() + } + @Test fun testPingMethodConstant() { assertEquals("ping", WsMethods.PING) + assertEquals("gateway.ping", WsMethods.GATEWAY_PING) } @Test @@ -1716,7 +1805,7 @@ class HermesWsClientTest { webSocket: WebSocket, text: String, ) { - if (text.contains(""""method":"ping"""")) { + if (text.contains(""""method":"ping"""") || text.contains(""""method":"gateway.ping"""")) { pingReceived = true val id = Regex(""""id":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: "1" webSocket.send( diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt index 3bc70e35..6efcd0a2 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt @@ -149,6 +149,25 @@ class ChatStreamingControllerTest { assertEquals("real reasoning trace", streamingState.value.streamingMessage?.reasoningText) } + @Test + fun handleMessageToken_schedulesTrailingFlushForBufferedTail() = + runTest { + val uiState = MutableStateFlow(ChatUiState()) + val streamingState = MutableStateFlow(StreamingState()) + val controller = controller(this, uiState, streamingState, isTestEnvironment = { false }) + + controller.handleMessageToken(WsEvent.MessageToken("First", "session")) + assertEquals("First", streamingState.value.streamingMessage?.content) + + // Second token arrives immediately (<33ms) -> held in buffer + controller.handleMessageToken(WsEvent.MessageToken(" Second", "session")) + assertEquals("First", streamingState.value.streamingMessage?.content) + + // Advance virtual time by 35ms -> trailing flush executes + testScheduler.advanceTimeBy(35L) + assertEquals("First Second", streamingState.value.streamingMessage?.content) + } + private fun controller( scope: CoroutineScope, uiState: MutableStateFlow, From fad2dd3b86f15fc2464344729de86a1a26c8a009 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:14:14 +0300 Subject: [PATCH 30/42] feat(chat): upgrade tool rendering, add missing renderers, and clamp output budget (#1086) --- .../m57/hermescontrol/ui/chat/ToolBubble.kt | 50 +++++- .../ui/chat/ToolSchemaRegistry.kt | 54 ++++++ .../hermescontrol/ui/chat/tool/ToolJson.kt | 18 +- .../ui/chat/tool/ToolRendererRegistry.kt | 20 +++ .../ui/chat/tool/ToolViewBuilder.kt | 13 +- .../ui/chat/tool/render/BrowserRenderers.kt | 96 +++++++++++ .../chat/tool/render/DelegateTaskRenderer.kt | 139 ++++++++++++++++ .../ui/chat/tool/render/MediaRenderers.kt | 29 ++++ .../chat/tool/render/SearchFilesRenderer.kt | 69 ++++++++ .../ui/chat/tool/ToolViewBuilderTest.kt | 155 ++++++++++++++++++ .../chat/tool/ToolViewCharacterizationTest.kt | 6 +- 11 files changed, 640 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/DelegateTaskRenderer.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SearchFilesRenderer.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index d2e0a705..6b1fff67 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -16,12 +16,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.CheckCircle @@ -45,7 +48,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext @@ -58,6 +63,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors @@ -84,6 +90,7 @@ internal fun composeToolSummaryLines( ): Pair? { val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true) val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title } + val statsSuffix = view.diffStats?.let { " (+${it.added}/-${it.removed})" } ?: "" val firstLine: String val secondLine: String? @@ -93,6 +100,7 @@ internal fun composeToolSummaryLines( if (subtitle != null) { append(subtitle) } + append(statsSuffix) view.countLabel?.let { append(" ($it)") } } secondLine = null @@ -100,6 +108,7 @@ internal fun composeToolSummaryLines( firstLine = buildString { append(view.title) + append(statsSuffix) view.countLabel?.let { append(" ($it)") } } secondLine = subtitle @@ -233,6 +242,11 @@ internal fun ToolBubble( SelectionContainer { Text( text = message.content, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), style = MaterialTheme.typography.bodySmall.copy( color = contentColor.copy(alpha = 0.8f), @@ -301,6 +315,11 @@ internal fun ToolBubble( SelectionContainer { Text( text = message.content, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), style = MaterialTheme.typography.bodySmall.copy( color = contentColor.copy(alpha = 0.8f), @@ -383,6 +402,11 @@ private fun ExpandedToolContent( view.stdout?.let { Text( text = it, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), style = MaterialTheme.typography.bodySmall.copy( color = contentColor.copy(alpha = 0.9f), @@ -394,6 +418,11 @@ private fun ExpandedToolContent( view.stderr?.let { Text( text = it, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), style = MaterialTheme.typography.bodySmall.copy( color = contentColor.copy(alpha = 0.6f), @@ -408,13 +437,27 @@ private fun ExpandedToolContent( text = stringResource(R.string.chat_tool_exit_code, code), style = MaterialTheme.typography.labelSmall.copy( - color = statusColors.error, + color = statusColors.warning, fontWeight = FontWeight.Medium, ), ) } } } else { + // ── Generated/resolved image preview ── + view.imageUrl?.let { url -> + AsyncImage( + model = url, + contentDescription = view.title, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .clip(RoundedCornerShape(8.dp)), + ) + } + // ── File diff ── if (view.inlineDiff != null) { DiffViewCard( @@ -479,6 +522,11 @@ private fun ExpandedToolContent( if (view.detail.isNotBlank() && view.inlineDiff == null) { Text( text = view.detail, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), style = MaterialTheme.typography.bodySmall.copy( color = contentColor.copy(alpha = 0.9f), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt index 067ff205..4af0ace6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt @@ -131,6 +131,12 @@ object ToolSchemaRegistry { summaryArgKey = null, icon = Icons.Filled.Checklist, ), + "todo_list" to + ToolDisplayConfig( + name = "todo_list", + summaryArgKey = null, + icon = Icons.Filled.Checklist, + ), "fact_store" to ToolDisplayConfig( name = "fact_store", @@ -150,6 +156,12 @@ object ToolSchemaRegistry { summaryArgKey = "action", icon = Icons.Filled.Schedule, ), + "cronjob_manage" to + ToolDisplayConfig( + name = "cronjob_manage", + summaryArgKey = "action", + icon = Icons.Filled.Schedule, + ), "memory" to ToolDisplayConfig( name = "memory", @@ -168,6 +180,12 @@ object ToolSchemaRegistry { summaryArgKey = "action", icon = Icons.Filled.Settings, ), + "process_manage" to + ToolDisplayConfig( + name = "process_manage", + summaryArgKey = "action", + icon = Icons.Filled.Settings, + ), "skill_manage" to ToolDisplayConfig( name = "skill_manage", @@ -187,6 +205,42 @@ object ToolSchemaRegistry { summaryArgKey = null, icon = Icons.Filled.Keyboard, ), + "browser_scroll" to + ToolDisplayConfig( + name = "browser_scroll", + summaryArgKey = "direction", + icon = Icons.Filled.TouchApp, + ), + "browser_back" to + ToolDisplayConfig( + name = "browser_back", + summaryArgKey = null, + icon = Icons.Filled.Language, + ), + "browser_press" to + ToolDisplayConfig( + name = "browser_press", + summaryArgKey = "key", + icon = Icons.Filled.Keyboard, + ), + "browser_get_images" to + ToolDisplayConfig( + name = "browser_get_images", + summaryArgKey = null, + icon = Icons.Filled.Image, + ), + "browser_vision" to + ToolDisplayConfig( + name = "browser_vision", + summaryArgKey = "question", + icon = Icons.Filled.Visibility, + ), + "browser_console" to + ToolDisplayConfig( + name = "browser_console", + summaryArgKey = null, + icon = Icons.Filled.Terminal, + ), "browser_cdp" to ToolDisplayConfig( name = "browser_cdp", diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt index e2bd3347..21ef8f3d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt @@ -16,6 +16,18 @@ import kotlinx.serialization.json.JsonPrimitive internal object ToolJson { private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE) + const val MAX_TOOL_RENDER_CHARS = 20_000 + + fun clampForDisplay( + value: String, + max: Int = MAX_TOOL_RENDER_CHARS, + ): String { + if (value.length <= max) return value + val omitted = value.length - max + return value.take(max) + + "\n… $omitted more characters truncated — use Copy for the full output." + } + /** First non-blank string value among [keys], trimmed. */ fun firstString( record: JsonObject?, @@ -294,7 +306,11 @@ internal object ToolJson { } fun durationLabel(result: JsonObject?): String? { - val seconds = numberValue(result?.get("duration_s")) ?: return null + val seconds = + numberValue(result?.get("duration_s")) + ?: numberValue(result?.get("duration_seconds")) + ?: numberValue(result?.get("total_duration_seconds")) + ?: return null return formatDurationSeconds(seconds) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt index dfacec8f..3df58405 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt @@ -1,11 +1,16 @@ package com.m57.hermescontrol.ui.chat.tool +import com.m57.hermescontrol.ui.chat.tool.render.BrowserActionRenderer import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserConsoleRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserImagesRenderer import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserVisionRenderer import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer +import com.m57.hermescontrol.ui.chat.tool.render.DelegateTaskRenderer import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer @@ -15,11 +20,13 @@ import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SearchFilesRenderer import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TextToSpeechRenderer import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer @@ -44,11 +51,14 @@ internal object ToolRendererRegistry { "terminal" to TerminalRenderer, "execute_code" to TerminalRenderer, "read_terminal" to ReadTerminalRenderer, + // Delegation + "delegate_task" to DelegateTaskRenderer, // Files "read_file" to ReadFileRenderer, "edit_file" to FileEditRenderer, "patch" to FileEditRenderer, "write_file" to FileEditRenderer, + "search_files" to SearchFilesRenderer, // Web "web_search" to WebSearchRenderer, "web_extract" to WebExtractRenderer, @@ -59,14 +69,23 @@ internal object ToolRendererRegistry { "browser_click" to BrowserClickRenderer, "browser_fill" to BrowserTypeRenderer, "browser_type" to BrowserTypeRenderer, + "browser_scroll" to BrowserActionRenderer, + "browser_back" to BrowserActionRenderer, + "browser_press" to BrowserActionRenderer, + "browser_get_images" to BrowserImagesRenderer, + "browser_vision" to BrowserVisionRenderer, + "browser_console" to BrowserConsoleRenderer, // Memory / facts "memory" to MemoryRenderer, "fact_store" to FactStoreRenderer, // Task management "cronjob" to CronjobRenderer, + "cronjob_manage" to CronjobRenderer, "todo" to TodoRenderer, + "todo_list" to TodoRenderer, "session_search" to SessionSearchRenderer, "process" to ProcessRenderer, + "process_manage" to ProcessRenderer, // Skills / tool discovery "skills_list" to SkillsListRenderer, "skill_view" to SkillViewRenderer, @@ -75,6 +94,7 @@ internal object ToolRendererRegistry { // Media "image_generate" to ImageGenerateRenderer, "vision_analyze" to VisionAnalyzeRenderer, + "text_to_speech" to TextToSpeechRenderer, // Projects "project_list" to ProjectListRenderer, "project_create" to ProjectMutateRenderer, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index 3eb334b0..14ae55fe 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -89,19 +89,24 @@ object ToolViewBuilder { ToolCounts.countLabelFor(call) } + val clampedDetail = ToolJson.clampForDisplay(detail) + val clampedStdout = extras.stdout?.let { ToolJson.clampForDisplay(it) } + val clampedStderr = extras.stderr?.let { ToolJson.clampForDisplay(it) } + val clampedDiff = extras.inlineDiff?.ifEmpty { null }?.let { ToolJson.clampForDisplay(it) } + return ToolView( status = status, title = title, subtitle = subtitle, - detail = detail, + detail = clampedDetail, detailLabel = extras.detailLabel, countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" }, durationLabel = ToolJson.durationLabel(call.result), - stdout = extras.stdout, - stderr = extras.stderr, + stdout = clampedStdout, + stderr = clampedStderr, exitCode = extras.exitCode, terminalCommand = extras.terminalCommand, - inlineDiff = extras.inlineDiff?.ifEmpty { null }, + inlineDiff = clampedDiff, diffPath = extras.diffPath, diffStats = extras.diffStats, imageUrl = imageUrlFor(call.args, call.result), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt index e459bbce..26b05406 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat.tool.render import com.m57.hermescontrol.ui.chat.tool.ToolCall import com.m57.hermescontrol.ui.chat.tool.ToolJson import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import kotlinx.serialization.json.JsonArray /** `browser_navigate`: hostname titles, including the failed-to-open form. */ internal object BrowserNavigateRenderer : ToolRenderer { @@ -104,3 +105,98 @@ internal object BrowserTypeRenderer : ToolRenderer { .ifEmpty { "Filled page input" } } } + +/** browser_scroll / browser_back / browser_press: one-line acks. */ +internal object BrowserActionRenderer : ToolRenderer { + override fun doneTitle(call: ToolCall): String? = + when (call.name) { + "browser_scroll" -> { + "Scrolled ${ToolJson.firstString(call.result, listOf("scrolled")) + .ifEmpty { ToolJson.firstString(call.args, listOf("direction")) }.ifEmpty { "page" }}" + } + + "browser_back" -> { + val url = ToolJson.firstString(call.result, listOf("url")) + if (url.isNotEmpty()) "Went back to ${ToolJson.hostnameOf(url)}" else "Went back" + } + + "browser_press" -> { + "Pressed ${ToolJson.firstString(call.result, listOf("pressed")) + .ifEmpty { ToolJson.firstString(call.args, listOf("key")) }}" + } + + else -> { + null + } + } + + override fun subtitle(call: ToolCall): String = "" + + override fun detail(call: ToolCall): String = "" +} + +/** browser_get_images: image inventory of the current page. */ +internal object BrowserImagesRenderer : ToolRenderer { + override fun doneTitle(call: ToolCall): String { + val n = ToolJson.intValue(call.result?.get("count")) ?: 0 + return "Found $n image${if (n == 1) "" else "s"}" + } + + override fun detail(call: ToolCall): String = + (call.result?.get("images") as? JsonArray) + ?.take(12) + ?.mapNotNull { el -> + val img = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val src = ToolJson.firstString(img, listOf("src")) + val alt = ToolJson.firstString(img, listOf("alt")) + listOf(alt, src) + .filter { it.isNotEmpty() } + .joinToString(" — ") + .takeIf { it.isNotEmpty() } + ?.let { "- $it" } + }?.joinToString("\n") ?: "" +} + +/** browser_vision: screenshot Q&A — the analysis text is the payload. */ +internal object BrowserVisionRenderer : ToolRenderer { + override fun pendingTitle(call: ToolCall): String = "Looking at page" + + override fun doneTitle(call: ToolCall): String = "Analyzed page" + + override fun subtitle(call: ToolCall): String = + ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("question")), 120) + + override fun detail(call: ToolCall): String = + ToolJson + .firstString(call.result, listOf("analysis")) + .ifEmpty { ToolJson.firstString(call.result, listOf("note")) } +} + +/** browser_console: message/error counts, or a JS eval result. */ +internal object BrowserConsoleRenderer : ToolRenderer { + override fun doneTitle(call: ToolCall): String? { + val result = call.result ?: return null + if (result.containsKey("result")) return "Evaluated JS" + val msgs = ToolJson.intValue(result["total_messages"]) ?: 0 + val errs = ToolJson.intValue(result["total_errors"]) ?: 0 + return "Console: $msgs message${if (msgs == 1) "" else "s"}" + + if (errs > 0) ", $errs error${if (errs == 1) "" else "s"}" else "" + } + + override fun detail(call: ToolCall): String { + val result = call.result ?: return "" + result["result"]?.let { return it.toString() } + val lines = + (result["console_messages"] as? JsonArray)?.take(30)?.mapNotNull { el -> + val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + "[${ToolJson.firstString(m, listOf("type")).ifEmpty { "log" }}] " + + ToolJson.compactPreview(ToolJson.firstString(m, listOf("text")), 200) + } ?: emptyList() + val errors = + (result["js_errors"] as? JsonArray)?.take(10)?.mapNotNull { el -> + val e = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + "[error] ${ToolJson.compactPreview(ToolJson.firstString(e, listOf("message")), 200)}" + } ?: emptyList() + return (errors + lines).joinToString("\n") + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/DelegateTaskRenderer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/DelegateTaskRenderer.kt new file mode 100644 index 00000000..7bcde710 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/DelegateTaskRenderer.kt @@ -0,0 +1,139 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive + +/** + * `delegate_task`: fan-out of subagent tasks. + * Covers joined results, background dispatch handles, and list/steer/stop acks. + */ +internal object DelegateTaskRenderer : ToolRenderer { + private fun goals(call: ToolCall): List { + val tasks = call.args?.get("tasks") as? JsonArray + val fromTasks = + tasks?.mapIndexedNotNull { i, el -> + ToolJson + .parseMaybeObject(el) + ?.let { ToolJson.firstString(it, listOf("goal")) } + ?.ifEmpty { "Task ${i + 1}" } + } ?: emptyList() + if (fromTasks.isNotEmpty()) return fromTasks + return ToolJson + .firstString(call.args, listOf("goal")) + .takeIf { it.isNotEmpty() } + ?.let { listOf(it) } ?: emptyList() + } + + private fun action(call: ToolCall) = ToolJson.firstString(call.args, listOf("action")).ifEmpty { "spawn" } + + override fun pendingTitle(call: ToolCall): String = + when (action(call)) { + "list" -> { + "Listing subagents" + } + + "steer" -> { + "Steering subagent" + } + + "stop" -> { + "Stopping subagent" + } + + else -> { + val n = goals(call).size.coerceAtLeast(1) + "Delegating $n task${if (n == 1) "" else "s"}" + } + } + + override fun doneTitle(call: ToolCall): String { + val result = call.result + return when { + action(call) == "list" -> { + val n = ToolJson.intValue(result?.get("count")) ?: 0 + "$n subagent${if (n == 1) "" else "s"}" + } + + action(call) == "steer" -> { + "Steer queued" + } + + action(call) == "stop" -> { + "Stop requested" + } + + ToolJson.firstString(result, listOf("status")) == "dispatched" -> { + val n = ToolJson.intValue(result?.get("count")) ?: goals(call).size + "Dispatched $n background task${if (n == 1) "" else "s"}" + } + + else -> { + val results = result?.get("results") as? JsonArray + val n = results?.size ?: goals(call).size.coerceAtLeast(1) + val failed = + results?.count { + ToolJson.firstString(ToolJson.parseMaybeObject(it), listOf("status")) == "failed" + } ?: 0 + if (failed > 0) { + "Delegated $n tasks · $failed failed" + } else { + "Delegated $n task${if (n == 1) "" else "s"}" + } + } + } + } + + override fun subtitle(call: ToolCall): String = ToolJson.compactPreview(goals(call).firstOrNull() ?: "", 120) + + override fun detail(call: ToolCall): String { + val result = call.result + // list action: one row per live subagent + (result?.get("subagents") as? JsonArray)?.let { subs -> + return subs + .mapNotNull { el -> + val s = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val goal = ToolJson.compactPreview(ToolJson.firstString(s, listOf("goal")), 80) + val status = ToolJson.firstString(s, listOf("status")) + val secs = ToolJson.numberValue(s["running_seconds"]) + val time = secs?.let { " · ${ToolJson.formatDurationSeconds(it)}" } ?: "" + "- [$status] $goal$time" + }.joinToString("\n") + } + // joined spawn: per-task result rows + val rows = + (result?.get("results") as? JsonArray)?.mapNotNull { el -> + val r = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val idx = ToolJson.intValue(r["task_index"]) ?: 0 + val ok = ToolJson.firstString(r, listOf("status")) != "failed" + val marker = if (ok) "[x]" else "[!]" + val goal = goals(call).getOrNull(idx) ?: "Task ${idx + 1}" + val model = ToolJson.firstString(r, listOf("model")) + val dur = + ToolJson + .numberValue(r["duration_seconds"]) + ?.let { ToolJson.formatDurationSeconds(it) } ?: "" + val meta = listOf(model, dur).filter { it.isNotEmpty() }.joinToString(" · ") + val summary = + ToolJson.compactPreview( + ToolJson.firstString(r, listOf("summary", "error")), + 160, + ) + buildString { + append("$marker ${ToolJson.compactPreview(goal, 80)}") + if (meta.isNotEmpty()) append(" ($meta)") + if (summary.isNotEmpty()) append("\n $summary") + } + } ?: emptyList() + if (rows.isNotEmpty()) return rows.joinToString("\n") + // dispatched handle / steer / stop acks + val note = ToolJson.firstString(result, listOf("note")) + val goalsArr = + (result?.get("goals") as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content } + ?.joinToString("\n") { "- ${ToolJson.compactPreview(it, 100)}" } ?: "" + return listOf(goalsArr, note).filter { it.isNotEmpty() }.joinToString("\n\n") + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt index faece561..648c2223 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt @@ -55,3 +55,32 @@ internal object VisionAnalyzeRenderer : ToolRenderer { } } } + +/** `text_to_speech`: audio synthesis. Success payload carries file_path(s), provider, chunk_count. */ +internal object TextToSpeechRenderer : ToolRenderer { + override fun pendingTitle(call: ToolCall): String = "Generating speech" + + override fun doneTitle(call: ToolCall): String = "Generated speech" + + override fun subtitle(call: ToolCall): String { + val provider = ToolJson.firstString(call.result, listOf("provider")) + val chunks = ToolJson.intValue(call.result?.get("chunk_count")) + val parts = + listOfNotNull( + provider.takeIf { it.isNotEmpty() }, + chunks?.takeIf { it > 1 }?.let { "$it chunks" }, + ) + return parts + .joinToString(" · ") + .ifEmpty { ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("text")), 120) } + } + + override fun detail(call: ToolCall): String { + val text = ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("text")), 400) + val path = ToolJson.firstString(call.result, listOf("file_path", "output_path")) + return listOfNotNull( + text.takeIf { it.isNotEmpty() }?.let { "“$it”" }, + path.takeIf { it.isNotEmpty() }?.let { "Saved to $it" }, + ).joinToString("\n\n") + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SearchFilesRenderer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SearchFilesRenderer.kt new file mode 100644 index 00000000..40f913a4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SearchFilesRenderer.kt @@ -0,0 +1,69 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras +import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive + +/** + * `search_files`: ripgrep-backed content/file search. + * Handles both result shapes: dense path-grouped `matches_text` string (≥5 hits) + * and `matches:[{path,line,content}]` (<5 hits), plus `files:[path]`. + */ +internal object SearchFilesRenderer : ToolRenderer { + private fun pattern(call: ToolCall) = ToolJson.firstString(call.args, listOf("pattern")) + + override fun pendingTitle(call: ToolCall): String? = + pattern(call).takeIf { it.isNotEmpty() }?.let { "Searching “${ToolJson.compactPreview(it, 48)}”" } + + override fun doneTitle(call: ToolCall): String? { + val p = pattern(call).takeIf { it.isNotEmpty() } ?: return null + val total = ToolJson.intValue(call.result?.get("total_count")) + val suffix = total?.let { " · $it match${if (it == 1) "" else "es"}" } ?: "" + return "Searched “${ToolJson.compactPreview(p, 48)}”$suffix" + } + + override fun subtitle(call: ToolCall): String { + val path = ToolJson.firstString(call.args, listOf("path")).takeIf { it != "." && it.isNotEmpty() } ?: "" + val glob = ToolJson.firstString(call.args, listOf("file_glob")) + return listOf(path, glob).filter { it.isNotEmpty() }.joinToString(" · ") + } + + override fun detail(call: ToolCall): String { + val result = call.result ?: return "" + // Dense ≥5-match block: already ripgrep-grouped, use verbatim. + val dense = ToolJson.firstString(result, listOf("matches_text")) + val body = + if (dense.isNotEmpty()) { + dense + } else { + (result["matches"] as? JsonArray) + ?.mapNotNull { el -> + val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val path = ToolJson.firstString(m, listOf("path")) + val line = ToolJson.intValue(m["line"]) + val content = ToolJson.firstString(m, listOf("content")) + "$path:${line ?: "?"}: ${content.trim()}" + }?.joinToString("\n") + ?: (result["files"] as? JsonArray) + ?.mapNotNull { + (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content + }?.joinToString("\n") ?: "" + } + val hint = + if (ToolJson.boolTrue(result["truncated"])) { + ToolJson.firstString(result, listOf("_hint")).ifEmpty { "Results truncated" } + } else { + "" + } + return listOf(body, hint).filter { it.isNotEmpty() }.joinToString("\n\n") + } + + override fun extras( + call: ToolCall, + status: ToolViewStatus, + ): ToolViewExtras = ToolViewExtras(detailLabel = "Matches") +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt index 6f7a7bc9..7b408a4f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt @@ -265,4 +265,159 @@ class ToolViewBuilderTest { assertEquals(ToolViewStatus.ERROR, view.status) assertTrue(view.error?.contains("Permission denied") == true) } + + // ── name aliases ────────────────────────────────────────────────────── + + @Test + fun `process_manage alias activates ProcessRenderer`() { + val view = + build( + "process_manage", + """{"action":"list"}""", + """{"processes":[{"pid":123,"command":"sleep 100","status":"running"}]}""", + ) + + assertEquals("list", view.subtitle) + assertTrue(view.detail.contains("sleep 100")) + } + + @Test + fun `todo_list alias activates TodoRenderer`() { + val view = + build( + "todo_list", + """{"action":"create"}""", + """{"summary":{"total":1,"pending":1,"completed":0},"todos":""" + + """[{"id":"1","content":"write tests","status":"pending"}]}""", + ) + + assertEquals("1 item (1 pending)", view.subtitle) + assertTrue(view.detail.contains("[ ] 1. write tests")) + } + + @Test + fun `cronjob_manage alias activates CronjobRenderer`() { + val view = + build( + "cronjob_manage", + """{"action":"list"}""", + """{"jobs":[{"name":"backup","schedule":"0 0 * * *"}]}""", + ) + + assertEquals("1 cron job", view.subtitle) + assertTrue(view.detail.contains("- backup · 0 0 * * *")) + } + + // ── new renderers ───────────────────────────────────────────────────── + + @Test + fun `search_files dense format parses matches_text and count`() { + val view = + build( + "search_files", + """{"pattern":"fun connect","path":"app/src"}""", + """{"total_count":2,"matches_text":"app/src/A.kt:10: fun connect()\napp/src/B.kt:20: fun connect()"}""", + ) + + assertEquals("Searched “fun connect” · 2 matches", view.title) + assertEquals("app/src", view.subtitle) + assertTrue(view.detail.contains("app/src/A.kt:10: fun connect()")) + assertEquals("Matches", view.detailLabel) + } + + @Test + fun `search_files sparse array format formats hits`() { + val view = + build( + "search_files", + """{"pattern":"test","path":"."}""", + """{"total_count":1,"matches":[{"path":"foo.kt","line":42,"content":"val test = 1"}]}""", + ) + + assertEquals("Searched “test” · 1 match", view.title) + assertTrue(view.detail.contains("foo.kt:42: val test = 1")) + } + + @Test + fun `delegate_task handles joined spawn with status markers`() { + val view = + build( + "delegate_task", + """{"action":"spawn","tasks":[{"goal":"Research architecture"},{"goal":"Write code"}]}""", + """{"results":[{"task_index":0,"status":"completed","model":"claude-3-7","summary":"Researched OK",""" + + """"duration_seconds":12.5},{"task_index":1,"status":"failed","error":"Build broke",""" + + """"duration_seconds":4.0}]}""", + ) + + assertEquals("Delegated 2 tasks · 1 failed", view.title) + assertEquals("Research architecture", view.subtitle) + assertTrue(view.detail.contains("[x] Research architecture")) + assertTrue(view.detail.contains("claude-3-7")) + assertTrue(view.detail.contains("[!] Write code")) + assertTrue(view.detail.contains("Build broke")) + } + + @Test + fun `delegate_task handles list action`() { + val view = + build( + "delegate_task", + """{"action":"list"}""", + """{"count":1,"subagents":[{"goal":"Background indexing","status":"running",""" + + """"running_seconds":35.0}]}""", + ) + + assertEquals("1 subagent", view.title) + assertTrue(view.detail.contains("- [running] Background indexing · 35s")) + } + + @Test + fun `text_to_speech formats provider and audio path`() { + val view = + build( + "text_to_speech", + """{"text":"Hello world"}""", + """{"provider":"edge","chunk_count":1,"file_path":"/tmp/audio.mp3"}""", + ) + + assertEquals("Generated speech", view.title) + assertEquals("edge", view.subtitle) + assertTrue(view.detail.contains("“Hello world”")) + assertTrue(view.detail.contains("Saved to /tmp/audio.mp3")) + } + + @Test + fun `browser action renderers format scroll, back, vision and console`() { + val scroll = build("browser_scroll", """{"direction":"down"}""", """{"scrolled":"down"}""") + assertEquals("Scrolled down", scroll.title) + + val back = build("browser_back", """{}""", """{"url":"https://example.com/docs"}""") + assertEquals("Went back to example.com/docs", back.title) + + val vision = + build("browser_vision", """{"question":"Is there a login button?"}""", """{"analysis":"Yes, top right"}""") + assertEquals("Analyzed page", vision.title) + assertEquals("Is there a login button?", vision.subtitle) + assertEquals("Yes, top right", vision.detail) + + val console = + build( + "browser_console", + """{}""", + """{"total_messages":5,"total_errors":1,"js_errors":[{"message":"Uncaught TypeError"}]}""", + ) + assertEquals("Console: 5 messages, 1 error", console.title) + assertTrue(console.detail.contains("[error] Uncaught TypeError")) + } + + @Test + fun `output is clamped to maximum display chars`() { + val longOutput = "a".repeat(25_000) + val view = build("terminal", """{"command":"dump"}""", """{"output":"$longOutput"}""") + + val stdout = view.stdout + assertNotNull(stdout) + assertTrue(stdout!!.length < 25_000) + assertTrue(stdout.contains("more characters truncated")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt index b9c08aab..e12fae36 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt @@ -555,7 +555,7 @@ class ToolViewCharacterizationTest { // ── delegation / messaging fall back to the generic engine ─────────── @Test - fun `delegate_task uses generic title and unwrapped response`() { + fun `delegate_task uses DelegateTaskRenderer`() { val view = build( "delegate_task", @@ -563,8 +563,8 @@ class ToolViewCharacterizationTest { """{"response":"All done","duration_s":63}""", ) - assertEquals("Delegate Task", view.title) - assertEquals("All done", view.subtitle) + assertEquals("Delegated 1 task", view.title) + assertEquals("Fix tests", view.subtitle) assertEquals("", view.detail) assertEquals("1m 3s", view.durationLabel) } From 5dc38962372aaa99623ac5a6ab9b9eea02840e13 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:41:24 +0300 Subject: [PATCH 31/42] feat(sessions): branch tree flattening with group recency and collapsible pinned section (#1087) --- .../data/model/SessionBranchTree.kt | 116 +++++++++++++++++ .../data/model/SessionListResponse.kt | 9 ++ .../data/remote/HermesApiService.kt | 7 + .../ui/sessions/SessionsScreen.kt | 121 ++++++++++++------ .../ui/sessions/SessionsViewModel.kt | 76 ++++++++--- .../components/PinnedSectionHeader.kt | 62 +++++++++ .../ui/sessions/components/SessionCard.kt | 28 ++++ .../data/model/SessionTreeTest.kt | 94 ++++++++++---- .../ui/sessions/SessionsViewModelTest.kt | 62 ++++++++- 9 files changed, 482 insertions(+), 93 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/SessionBranchTree.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/PinnedSectionHeader.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionBranchTree.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionBranchTree.kt new file mode 100644 index 00000000..056e9e4c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionBranchTree.kt @@ -0,0 +1,116 @@ +package com.m57.hermescontrol.data.model + +private fun recency(s: SessionInfo): Double = s.last_active ?: s.started_at ?: 0.0 + +/** + * Port of desktop session-branch-tree.ts#flattenSessionsWithBranches. + * + * - Aliases _lineage_root_id so forks find compression-projected parents. + * - Sorts sibling branches by own recency; sorts roots by groupRecency + * (max over subtree) so activity on any branch lifts the whole family. + * - preserveOrder keeps caller-chosen root order (pinned section). + * - Cycle-guarded; trailing sweep never drops an input row. + */ +fun flattenSessionsWithBranches( + sessions: List, + preserveOrder: Boolean = false, +): List { + if (sessions.size < 2) { + return sessions.map { it.toTreeItem(depth = 0, siblingIndex = 0, siblingCount = 1) } + } + + val byVisibleId = HashMap(sessions.size * 2) + for (s in sessions) { + byVisibleId[s.id] = s + s.lineageRootId + ?.trim() + ?.takeIf(String::isNotEmpty) + ?.let { byVisibleId[it] = s } + } + + val childrenByParent = HashMap>() + val nestedIds = HashSet() + for (s in sessions) { + val parentId = s.parent_session_id?.trim()?.takeIf(String::isNotEmpty) ?: continue + val parent = byVisibleId[parentId] ?: continue + if (parent.id == s.id) continue + nestedIds += s.id + childrenByParent.getOrPut(parent.id) { mutableListOf() } += s + } + childrenByParent.values.forEach { it.sortByDescending(::recency) } + + // Group sorts by its freshest member; memo pre-seed doubles as cycle guard. + val groupRecencyMemo = HashMap() + + fun groupRecency(s: SessionInfo): Double { + groupRecencyMemo[s.id]?.let { return it } + groupRecencyMemo[s.id] = recency(s) + val max = + childrenByParent[s.id] + .orEmpty() + .fold(recency(s)) { acc, child -> maxOf(acc, groupRecency(child)) } + groupRecencyMemo[s.id] = max + return max + } + + val out = mutableListOf() + val seen = HashSet() + + fun emit( + s: SessionInfo, + depth: Int, + siblingIndex: Int, + siblingCount: Int, + ) { + if (!seen.add(s.id)) return + out += s.toTreeItem(depth, siblingIndex, siblingCount) + val kids = childrenByParent[s.id].orEmpty() + kids.forEachIndexed { i, child -> emit(child, depth + 1, i, kids.size) } + } + + val roots = sessions.withIndex().filter { it.value.id !in nestedIds } + val ordered = + if (preserveOrder) { + roots + } else { + roots.sortedWith( + compareByDescending> { groupRecency(it.value) } + .thenBy { it.index }, + ) + } + ordered.forEachIndexed { i, r -> emit(r.value, 0, i, ordered.size) } + sessions.filter { it.id !in seen }.forEach { emit(it, 0, 0, 1) } // trailing sweep + return out +} + +private fun SessionInfo.toTreeItem( + depth: Int, + siblingIndex: Int, + siblingCount: Int, +): SessionTreeItem { + // G4 fix: fork identity from the field itself, never from load-set membership. + val isFork = !parent_session_id.isNullOrBlank() + val title = + title?.takeIf(String::isNotBlank) + ?: display_name?.takeIf(String::isNotBlank) + ?: if (isFork && depth > 0) { + "branch ${siblingIndex + 1}" + } else { + preview?.takeIf(String::isNotBlank)?.take(80) ?: "Untitled" + } + return SessionTreeItem( + session = this, + depth = depth, + branchStem = + if (depth == 0) { + null + } else if (siblingIndex == siblingCount - 1) { + "└─" + } else { + "├─" + }, + isFork = isFork, + forkDepth = if (depth > 0) depth else 0, + displayTitle = title, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index e01e6e0b..ab2e0eb0 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.model import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor @@ -78,6 +79,14 @@ data class SessionInfo( val display_name: String? = null, val model: String? = null, val terminal_backend: String? = null, + // Epoch seconds of the latest message (backend-computed, present on all + // list rows). Recency source of truth; started_at is the fallback. + val last_active: Double? = null, + // Original root id when this row is a compression-projected tip. Branch + // children reference the ROOT id in parent_session_id, so tree building + // must alias this id to this row (desktop byVisibleId parity). + @SerialName("_lineage_root_id") + val lineageRootId: String? = null, // Durable "keep" flag: pinned sessions are exempt from the auto-archive // sweep and are surfaced first in the history list (sorted client-side). // Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966). diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index a96eece2..90745c64 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -95,6 +95,7 @@ import com.m57.hermescontrol.data.model.RenameProfileRequest import com.m57.hermescontrol.data.model.SaveSkillContentRequest import com.m57.hermescontrol.data.model.ScanStatus import com.m57.hermescontrol.data.model.SessionDetailResponse +import com.m57.hermescontrol.data.model.SessionInfo import com.m57.hermescontrol.data.model.SessionListResponse import com.m57.hermescontrol.data.model.SessionMessagesResponse import com.m57.hermescontrol.data.model.SessionRenameRequest @@ -267,6 +268,12 @@ interface HermesApiService { @Query("profile") profile: String? = null, ): Response + @GET("api/sessions/{id}") + suspend fun getSessionInfo( + @Path("id", encoded = true) sessionId: String, + @Query("profile") profile: String? = null, + ): Response + @GET("api/model/info") suspend fun getModelInfo(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 0de2a4cb..9e5b1bbf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -100,9 +100,8 @@ import com.m57.hermescontrol.ChatScreen import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.SessionInfo -import com.m57.hermescontrol.data.model.SessionSearchResult import com.m57.hermescontrol.data.model.SessionTreeItem -import com.m57.hermescontrol.data.model.flattenSessionTree +import com.m57.hermescontrol.data.model.flattenSessionsWithBranches import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.common.EmptyState @@ -116,6 +115,8 @@ import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listItemSpacing +import com.m57.hermescontrol.ui.sessions.components.BranchRow +import com.m57.hermescontrol.ui.sessions.components.PinnedSectionHeader import com.m57.hermescontrol.ui.sessions.components.SearchResultCard import com.m57.hermescontrol.ui.sessions.components.SessionCard import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar @@ -192,10 +193,10 @@ private fun highlightText( } } -private fun displayedSessions(state: SessionsUiState): List = +internal fun displayedSessions(state: SessionsUiState): List = if (state.isSearchMode) { - state.searchResults.map { searchResult -> - val session = searchResult.toSessionInfo() + state.searchResults.map { result -> + val session = result.toSessionInfo() SessionTreeItem( session = session, depth = 0, @@ -208,7 +209,7 @@ private fun displayedSessions(state: SessionsUiState): List = ) } } else { - flattenSessionTree(state.displaySessions) + flattenSessionsWithBranches(state.displaySessions) } @OptIn(ExperimentalFoundationApi::class) @@ -238,6 +239,19 @@ fun SessionsScreen( displayedSessions(state) } + val pinnedItems = + remember( + state.isSearchMode, + state.pinnedSessions, + state.showHidden, + ) { + if (state.isSearchMode) { + emptyList() + } else { + flattenSessionsWithBranches(state.pinnedSessions, preserveOrder = true) + } + } + val hasSelection = state.selectedIds.isNotEmpty() val loadedMessageCount = state.sessions.sumOf { it.message_count ?: 0 } val automationSessionGroups = remember(sessionsToDisplay) { automationGroups(sessionsToDisplay.map { it.session }) } @@ -733,42 +747,44 @@ fun SessionsScreen( } val sessionCard: @Composable (SessionTreeItem) -> Unit = { item -> val session = item.session - SessionCard( - session = session, - displayTitle = item.displayTitle, - branchStem = item.branchStem, - isFork = item.isFork, - forkDepth = item.forkDepth, - query = state.searchQuery, - isSelecting = state.isSelecting, - isSelected = session.id in state.selectedIds, - isDeleting = session.id in state.deletingSessionIds, - isPinned = session.pinned == true, - isHidden = session.hidden == true, - highlightBackground = primaryContainer, - highlightForeground = onPrimaryContainer, - onCardClick = { - if (state.isSelecting) { + BranchRow(item = item) { + SessionCard( + session = session, + displayTitle = item.displayTitle, + branchStem = null, + isFork = item.isFork, + forkDepth = item.forkDepth, + query = state.searchQuery, + isSelecting = state.isSelecting, + isSelected = session.id in state.selectedIds, + isDeleting = session.id in state.deletingSessionIds, + isPinned = session.pinned == true, + isHidden = session.hidden == true, + highlightBackground = primaryContainer, + highlightForeground = onPrimaryContainer, + onCardClick = { + if (state.isSelecting) { + viewModel.toggleSessionSelection(session.id) + } else { + NavigationController.openChatSession(session.id) + } + }, + onToggleSelection = { viewModel.toggleSessionSelection(session.id) }, + onSelect = { + viewModel.toggleSelecting() viewModel.toggleSessionSelection(session.id) - } else { - NavigationController.openChatSession(session.id) - } - }, - onToggleSelection = { viewModel.toggleSessionSelection(session.id) }, - onSelect = { - viewModel.toggleSelecting() - viewModel.toggleSessionSelection(session.id) - }, - onRename = { - viewModel.openRenameDialog( - session.id, - item.displayTitle, - ) - }, - onTogglePin = { viewModel.togglePin(session.id) }, - onToggleHide = { viewModel.toggleHide(session.id) }, - onDelete = { viewModel.requestDeleteSession(session.id) }, - ) + }, + onRename = { + viewModel.openRenameDialog( + session.id, + item.displayTitle, + ) + }, + onTogglePin = { viewModel.togglePin(session.id) }, + onToggleHide = { viewModel.toggleHide(session.id) }, + onDelete = { viewModel.requestDeleteSession(session.id) }, + ) + } } LazyColumn( state = listState, @@ -793,6 +809,29 @@ fun SessionsScreen( renderSessionCard = sessionCard, ) } else { + if (pinnedItems.isNotEmpty() && !state.isSearchMode) { + item(key = "pinned_header") { + PinnedSectionHeader( + count = pinnedItems.size, + expanded = state.pinnedExpanded, + onToggle = { viewModel.togglePinnedExpanded() }, + ) + } + if (state.pinnedExpanded) { + items(pinnedItems, key = { "pin_${it.session.id}" }) { item -> + sessionCard(item) + } + } + item(key = "history_header") { + Text( + text = "History", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = spacing.xs), + ) + } + } items(sessionsToDisplay, key = { it.session.id }) { item -> sessionCard(item) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 0dc6f2b9..11f0d107 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -57,13 +57,6 @@ internal fun formatCompactCount(value: Int): String { private fun String.trimZeroes(): String = dropLastWhile { it == '0' }.trimEnd('.') -/** - * Stable sort: pinned sessions first. The backend only back-fills pins into - * page 1 (it doesn't lift them to the top), so the client owns the ordering. - * Stability keeps recency order intact within each group. - */ -private fun List.pinnedFirst(): List = sortedBy { it.pinned != true } - data class SessionsUiState( val section: HistorySection = HistorySection.CONVERSATIONS, val isLoading: Boolean = false, @@ -96,6 +89,7 @@ data class SessionsUiState( val searchResults: List = emptyList(), val searchError: String? = null, val showHidden: Boolean = false, + val pinnedExpanded: Boolean = true, ) { val isSearchMode: Boolean get() = searchQuery.isNotBlank() @@ -104,6 +98,9 @@ data class SessionsUiState( val displaySessions: List get() = if (showHidden) sessions else sessions.filter { it.hidden != true } + + val pinnedSessions: List + get() = displaySessions.filter { it.pinned == true } } class SessionsViewModel : @@ -146,10 +143,10 @@ class SessionsViewModel : if (requestGeneration == generation) { rawPaginationOffset = data.nextOffset(0) _uiState.update { - val newSessions = data.sessions.orEmpty().pinnedFirst() + val newSessions = data.sessions.orEmpty() val paging = SessionsPaging.resolveInitialPaging( - receivedCount = data.sessions.size, + receivedCount = minOf(data.sessions.size, PAGE_SIZE), pageSize = PAGE_SIZE, backendTotal = data.total, accumulatedCount = newSessions.size, @@ -160,6 +157,7 @@ class SessionsViewModel : hasMore = paging.hasMore, ) } + stitchMissingParents(requestGeneration) } }, ) @@ -172,8 +170,53 @@ class SessionsViewModel : private companion object { const val PAGE_SIZE = 50 const val SEARCH_DEBOUNCE_MS = 300L + const val MAX_STITCH_PER_PAGE = 8 + const val MAX_STITCH_ROUNDS = 3 } + private val resolvedParentCache = mutableMapOf() + + fun togglePinnedExpanded() { + _uiState.update { it.copy(pinnedExpanded = !it.pinnedExpanded) } + } + + private fun stitchMissingParents(requestGeneration: Long) = + viewModelScope.launch { + repeat(MAX_STITCH_ROUNDS) { + val loaded = _uiState.value.sessions + val known = + buildSet { + loaded.forEach { + add(it.id) + it.lineageRootId?.let(::add) + } + } + val missing = + loaded + .mapNotNull { it.parent_session_id?.trim()?.takeIf(String::isNotEmpty) } + .filter { it !in known } + .distinct() + .take(MAX_STITCH_PER_PAGE) + if (missing.isEmpty()) return@launch + + val fetched = + missing.mapNotNull { pid -> + resolvedParentCache[pid] ?: when ( + val r = + safeApiCall { ApiClient.hermesApi.getSessionInfo(pid) } + ) { + is NetworkResult.Success -> r.data?.also { resolvedParentCache[pid] = it } + is NetworkResult.Failure -> null + } + } + if (requestGeneration != generation || fetched.isEmpty()) return@launch + + _uiState.update { st -> + st.copy(sessions = (st.sessions + fetched).distinctBy { it.id }) + } + } + } + private val HistorySection.source: String? get() = if (this == HistorySection.AUTOMATIONS) "cron" else null @@ -234,10 +277,10 @@ class SessionsViewModel : onSuccess = { data -> if (requestGeneration != generation) return@safeLaunchLoad rawPaginationOffset = data.nextOffset(0) - val sessionsList = data.sessions.orEmpty().pinnedFirst() + val sessionsList = data.sessions.orEmpty() val paging = SessionsPaging.resolveInitialPaging( - receivedCount = data.sessions.size, + receivedCount = minOf(data.sessions.size, PAGE_SIZE), pageSize = PAGE_SIZE, backendTotal = data.total, accumulatedCount = sessionsList.size, @@ -252,6 +295,7 @@ class SessionsViewModel : selectedIds = emptySet(), ) } + stitchMissingParents(requestGeneration) }, onError = { errorMsg -> if (requestGeneration != generation) return@safeLaunchLoad @@ -295,11 +339,10 @@ class SessionsViewModel : val newSessions = (it.sessions + data.sessions) .distinctBy { s -> s.id } - .pinnedFirst() val addedNewItems = newSessions.size > it.sessions.size val paging = SessionsPaging.resolveLoadMorePaging( - receivedCount = data.sessions.size, + receivedCount = minOf(data.sessions.size, PAGE_SIZE), pageSize = PAGE_SIZE, addedNewItems = addedNewItems, backendTotal = data.total, @@ -312,6 +355,7 @@ class SessionsViewModel : hasMore = paging.hasMore, ) } + stitchMissingParents(requestGeneration) } is NetworkResult.Failure -> { @@ -540,8 +584,7 @@ class SessionsViewModel : it.copy( sessions = it.sessions - .map { s -> if (s.id == sessionId) s.copy(pinned = targetPinned) else s } - .pinnedFirst(), + .map { s -> if (s.id == sessionId) s.copy(pinned = targetPinned) else s }, toastMessage = if (targetPinned) { "Session pinned" @@ -583,8 +626,7 @@ class SessionsViewModel : it.copy( sessions = it.sessions - .map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s } - .pinnedFirst(), + .map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s }, toastMessage = if (targetHidden) { "Session hidden" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/PinnedSectionHeader.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/PinnedSectionHeader.kt new file mode 100644 index 00000000..c133830f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/PinnedSectionHeader.kt @@ -0,0 +1,62 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.theme.LocalSpacing + +@Composable +fun PinnedSectionHeader( + count: Int, + expanded: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + val spacing = LocalSpacing.current + Row( + modifier = + modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = onToggle) + .padding(vertical = spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.PushPin, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + text = "Pinned ($count)", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (expanded) "Collapse pinned sessions" else "Expand pinned sessions", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt index 9b574913..33b5ea7d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt @@ -39,17 +39,45 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionTreeItem import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType +@Composable +fun BranchRow( + item: SessionTreeItem, + modifier: Modifier = Modifier, + card: @Composable (SessionTreeItem) -> Unit, +) { + val indent = 12.dp * minOf(item.depth, 2) + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (item.branchStem != null) { + Text( + text = item.branchStem, + style = MaterialTheme.typography.labelMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = indent, end = 4.dp), + ) + } + Box(modifier = Modifier.weight(1f)) { + card(item) + } + } +} + fun sourceIcon(source: String?): ImageVector? = when (source?.lowercase()) { "telegram", "tg" -> Icons.AutoMirrored.Filled.Send diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/SessionTreeTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/SessionTreeTest.kt index 38bdd318..5d488afa 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/SessionTreeTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/SessionTreeTest.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.data.model import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class SessionTreeTest { @@ -8,46 +9,79 @@ class SessionTreeTest { fun nestsBranchesAndUsesDesktopFallbackNames() { val sessions = listOf( - SessionInfo(id = "unrelated", title = "Recent root"), - SessionInfo(id = "branch-b", parent_session_id = "parent"), - SessionInfo(id = "parent", title = "Original"), - SessionInfo(id = "branch-a", parent_session_id = "parent", preview = "copied prompt"), - SessionInfo(id = "nested", parent_session_id = "branch-a", title = "Named nested branch"), + SessionInfo(id = "unrelated", title = "Recent root", last_active = 10.0), + SessionInfo(id = "parent", title = "Original", last_active = 5.0), + SessionInfo(id = "branch-b", parent_session_id = "parent", last_active = 6.0), + SessionInfo( + id = "branch-a", + parent_session_id = "parent", + preview = "copied prompt", + last_active = 7.0, + ), + SessionInfo( + id = "nested", + parent_session_id = "branch-a", + title = "Named nested branch", + last_active = 8.0, + ), ) - val tree = flattenSessionTree(sessions) + val tree = flattenSessionsWithBranches(sessions) - assertEquals(listOf("unrelated", "parent", "branch-b", "branch-a", "nested"), tree.map { it.session.id }) - assertEquals(listOf(0, 0, 1, 1, 2), tree.map { it.depth }) + // groupRecency of parent is max(5.0, 6.0, 7.0, 8.0) = 8.0 + // unrelated is 10.0 -> unrelated first, then parent family + assertEquals(listOf("unrelated", "parent", "branch-a", "nested", "branch-b"), tree.map { it.session.id }) + assertEquals(listOf(0, 0, 1, 2, 1), tree.map { it.depth }) assertEquals( - listOf("Recent root", "Original", "branch 1", "branch 2", "Named nested branch"), - tree.map { - it.displayTitle - }, + listOf("Recent root", "Original", "branch 1", "Named nested branch", "branch 2"), + tree.map { it.displayTitle }, ) - // Fork flag + nesting depth (1 = child of root, 2 = fork of a fork). + // Fork flag + nesting depth assertEquals(listOf(false, false, true, true, true), tree.map { it.isFork }) - assertEquals(listOf(0, 0, 1, 1, 2), tree.map { it.forkDepth }) + assertEquals(listOf(0, 0, 1, 2, 1), tree.map { it.forkDepth }) } @Test - fun deepNestingMarksEveryForkWithDepth() { - // root → a (child 1) → a1 (child 1) ; root → b (child 2, last) - // a1 is fork-depth 2 (fork of a fork), b is fork-depth 1. + fun activeForkLiftsEntireFamilyAboveFresherUnrelatedRoot() { + // Parent has old recency (1.0). Fork has fresh recency (100.0). Unrelated has recency 50.0. val sessions = listOf( - SessionInfo(id = "root", title = "Root"), - SessionInfo(id = "a", parent_session_id = "root"), - SessionInfo(id = "b", parent_session_id = "root"), - SessionInfo(id = "a1", parent_session_id = "a"), + SessionInfo(id = "unrelated", title = "Unrelated", last_active = 50.0), + SessionInfo(id = "parent", title = "Parent", last_active = 1.0), + SessionInfo(id = "fork", parent_session_id = "parent", title = "Active Fork", last_active = 100.0), ) - val tree = flattenSessionTree(sessions) + val tree = flattenSessionsWithBranches(sessions) - assertEquals(listOf("root", "a", "a1", "b"), tree.map { it.session.id }) - assertEquals(listOf(0, 1, 2, 1), tree.map { it.depth }) - assertEquals(listOf(false, true, true, true), tree.map { it.isFork }) - assertEquals(listOf(0, 1, 2, 1), tree.map { it.forkDepth }) + assertEquals(listOf("parent", "fork", "unrelated"), tree.map { it.session.id }) + assertEquals(listOf(0, 1, 0), tree.map { it.depth }) + } + + @Test + fun aliasesLineageRootIdForCompressionProjectedTips() { + val sessions = + listOf( + SessionInfo(id = "tip_parent", lineageRootId = "root_parent", title = "Parent Tip", last_active = 5.0), + SessionInfo(id = "child", parent_session_id = "root_parent", title = "Child", last_active = 6.0), + ) + + val tree = flattenSessionsWithBranches(sessions) + + assertEquals(listOf("tip_parent", "child"), tree.map { it.session.id }) + assertEquals(listOf(0, 1), tree.map { it.depth }) + } + + @Test + fun preservesOrderWhenRequested() { + val sessions = + listOf( + SessionInfo(id = "pin1", title = "Pin 1", last_active = 10.0), + SessionInfo(id = "pin2", title = "Pin 2", last_active = 90.0), + ) + + val tree = flattenSessionsWithBranches(sessions, preserveOrder = true) + + assertEquals(listOf("pin1", "pin2"), tree.map { it.session.id }) } @Test @@ -59,9 +93,13 @@ class SessionTreeTest { SessionInfo(id = "b", parent_session_id = "a"), ) - val tree = flattenSessionTree(sessions) + val tree = flattenSessionsWithBranches(sessions) assertEquals(setOf("orphan", "a", "b"), tree.map { it.session.id }.toSet()) - assertEquals("orphan preview", tree.first { it.session.id == "orphan" }.displayTitle) + val orphanItem = tree.first { it.session.id == "orphan" } + assertEquals("orphan preview", orphanItem.displayTitle) + // Orphan keeps fork identity even without parent in loaded set + assertTrue(orphanItem.isFork) + assertEquals(0, orphanItem.depth) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index 1176f89c..cc4bcb60 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -331,10 +331,8 @@ class SessionsViewModelTest { // ── Pin / unpin ──────────────────────────────────────────────────────── @Test - fun `loaded list keeps pinned sessions on top`() { + fun `loaded list retains server order and identifies pinned sessions`() { val vm = createViewModel() - // Backend returns recency order with the pinned flag set; the client - // must lift pins above the rest while keeping the rest's order. coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns Response.success( SessionListResponse( @@ -349,15 +347,22 @@ class SessionsViewModelTest { vm.loadSessions() testDispatcher.scheduler.advanceUntilIdle() + // Sessions retain server order (family grouping is handled by flattenSessionsWithBranches) assertEquals( - listOf("old-pinned", "recent"), + listOf("recent", "old-pinned"), vm.uiState.value.sessions .map { it.id }, ) + // pinnedSessions derives the pinned items + assertEquals( + listOf("old-pinned"), + vm.uiState.value.pinnedSessions + .map { it.id }, + ) } @Test - fun `togglePin moves the session to the top and sets the flag`() { + fun `togglePin updates the session pinned state without mangling session order`() { val vm = createViewModel() coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns Response.success( @@ -374,16 +379,21 @@ class SessionsViewModelTest { testDispatcher.scheduler.advanceUntilIdle() assertEquals( - listOf("s-2", "s-1"), + listOf("s-1", "s-2"), vm.uiState.value.sessions .map { it.id }, ) assertEquals( true, vm.uiState.value.sessions - .first() + .first { it.id == "s-2" } .pinned, ) + assertEquals( + listOf("s-2"), + vm.uiState.value.pinnedSessions + .map { it.id }, + ) assertEquals("Session pinned", vm.uiState.value.toastMessage) } @@ -500,4 +510,42 @@ class SessionsViewModelTest { ) assertEquals("Session unhidden", vm.uiState.value.toastMessage) } + + @Test + fun `stitchMissingParents fetches missing parent sessions up to cap`() { + val vm = createViewModel() + coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns + Response.success( + SessionListResponse( + sessions = + listOf( + SessionInfo("child", parent_session_id = "parent", title = "Child"), + ), + total = 1, + ), + ) + coEvery { mockApi.getSessionInfo("parent") } returns + Response.success( + SessionInfo("parent", title = "Fetched Parent"), + ) + + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(2, vm.uiState.value.sessions.size) + assertTrue( + vm.uiState.value.sessions + .any { it.id == "parent" }, + ) + } + + @Test + fun `togglePinnedExpanded toggles expanded state`() { + val vm = createViewModel() + assertTrue(vm.uiState.value.pinnedExpanded) + vm.togglePinnedExpanded() + assertFalse(vm.uiState.value.pinnedExpanded) + vm.togglePinnedExpanded() + assertTrue(vm.uiState.value.pinnedExpanded) + } } From 0bb087c41e864a052e0a2a9358e8d23314f9ad6d Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:15:22 +0300 Subject: [PATCH 32/42] feat(chat): integrate fast mode toggle into generation controls dropdown (#1093) --- .../ui/chat/ChatModelSwitchDelegate.kt | 73 +++++++++- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 4 + .../hermescontrol/ui/chat/ChatViewModel.kt | 23 ++++ .../ui/chat/components/ChatComposer.kt | 8 ++ .../ui/chat/components/ComposerToolbar.kt | 130 +++++++++++++++--- app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-zh/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../ui/chat/ChatModelSwitchDelegateTest.kt | 71 +++++++++- .../ui/chat/ChatViewModelTest.kt | 86 ++++++++++++ 11 files changed, 388 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt index b295f6ef..6a1f687e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -24,6 +24,10 @@ private data class ActiveModelSwitch( val previousModel: String?, ) +private data class ActiveFastSwitch( + val targetFast: Boolean, +) + /** * Owns model picker loading/caching, in-session model switches, model capabilities, * pinned model toggling, and expensive model switch confirmation dialogs. @@ -48,6 +52,8 @@ class ChatModelSwitchDelegate( private val savePinnedModels: (List) -> Unit = { AuthManager.savePinnedModels(it) }, ) { private val pendingModelSwitchRequests = ConcurrentHashMap() + private val pendingFastSwitchRequests = ConcurrentHashMap() + private val fastRejectedModels = ConcurrentHashMap.newKeySet() private var activeModelSwitchConfirmation: ActiveModelSwitch? = null private var optimisticPreviousModel: String? = null private var cachedModelOptions: List = emptyList() @@ -195,6 +201,7 @@ class ChatModelSwitchDelegate( result: Any?, ) { val pending = pendingModelSwitchRequests.remove(id) + val pendingFast = pendingFastSwitchRequests.remove(id) val map = result as? Map<*, *> ?: return val key = map["key"] as? String if (key == "model") { @@ -209,6 +216,20 @@ class ChatModelSwitchDelegate( it.copy(modelSwitchConfirmMessage = confirmMessage) } } + } else if (key == "fast") { + val rawVal = map["value"] as? String + val confirmedFast = + if (rawVal != null) { + rawVal == "fast" || rawVal == "priority" + } else { + pendingFast?.targetFast ?: uiState.value.fastMode + } + uiState.update { + it.copy( + fastMode = confirmedFast, + isFastModeChanging = false, + ) + } } } @@ -234,6 +255,51 @@ class ChatModelSwitchDelegate( } } + fun toggleFastMode() { + val state = uiState.value + val sessionId = runtimeSessionId() ?: return + if (state.currentModelCapabilities?.fast != true) return + if (state.isFastModeChanging) return + + val target = !state.fastMode + uiState.update { it.copy(isFastModeChanging = true) } + scope.launch(ioDispatcher) { + wsSend( + WsMethods.CONFIG_SET, + mapOf( + "key" to "fast", + "value" to if (target) "fast" else "normal", + "session_id" to sessionId, + ), + ) { id -> + trackRequest(id, WsMethods.CONFIG_SET) + pendingFastSwitchRequests[id] = ActiveFastSwitch(target) + } + } + } + + fun handleConfigSetError( + id: String, + error: Any?, + ) { + val pendingFast = pendingFastSwitchRequests.remove(id) + if (pendingFast != null) { + val errorMsg = + when (error) { + is Map<*, *> -> error["message"] as? String ?: error.toString() + else -> error.toString() + }.lowercase() + val currentModel = uiState.value.currentSessionModel + if (currentModel != null && + (errorMsg.contains("fast mode is not available") || errorMsg.contains("unknown fast mode")) + ) { + fastRejectedModels.add(currentModel) + syncCurrentModelCapabilities() + } + uiState.update { it.copy(isFastModeChanging = false) } + } + } + fun setReasoningLevel(level: String?) { uiState.update { it.copy(reasoningLevel = level) } val sessionId = runtimeSessionId() ?: return @@ -276,7 +342,12 @@ class ChatModelSwitchDelegate( } else { val provider = label.substring(0, idx) val model = label.substring(idx + 1) - cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model) + val baseCaps = cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model) + if (fastRejectedModels.contains(label) && baseCaps?.fast == true) { + baseCaps.copy(fast = false) + } else { + baseCaps + } } } uiState.update { state -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 3223cc33..9067946a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -634,6 +634,10 @@ fun ChatScreen( onReasoningTap = { level -> viewModel.setReasoningLevel(level) }, canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning, supportsReasoning = state.currentModelCapabilities?.reasoning, + fastMode = state.fastMode, + fastSupported = state.currentModelCapabilities?.fast == true, + isFastModeChanging = state.isFastModeChanging, + onToggleFastMode = { viewModel.toggleFastMode() }, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 3e24796e..3077478c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -130,6 +130,9 @@ data class ChatUiState( val currentModelCapabilities: ModelCapabilities? = null, // Reasoning effort level for the current session val reasoningLevel: String? = null, + // Fast mode / Priority processing state for the current session + val fastMode: Boolean = false, + val isFastModeChanging: Boolean = false, val terminalBackend: String? = null, // Context-window meter (issue #756): tokens currently used by the session // prompt (numerator) and the active model's full context window (denominator). @@ -733,6 +736,10 @@ class ChatViewModel( val provider = info["provider"] as? String val reasoningEffort = info["reasoning_effort"] as? String val terminalBackend = info["terminal_backend"] as? String + val serviceTier = (info["service_tier"] as? String)?.trim()?.lowercase() + val fastFlag = + (info["fast"] as? Boolean) + ?: (if (serviceTier != null) serviceTier == "priority" else null) val newModelLabel = if (model != null && provider != null) { "$provider/$model" @@ -758,6 +765,8 @@ class ChatViewModel( } else { reasoningEffort }, + fastMode = fastFlag ?: state.fastMode, + isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging, terminalBackend = terminalBackend ?: state.terminalBackend, fullContextTokens = if (modelSwapped) null else state.fullContextTokens, ) @@ -1036,6 +1045,10 @@ class ChatViewModel( val provider = infoMap?.get("provider") as? String val reasoningEffort = infoMap?.get("reasoning_effort") as? String val terminalBackend = infoMap?.get("terminal_backend") as? String + val serviceTier = (infoMap?.get("service_tier") as? String)?.trim()?.lowercase() + val fastFlag = + (infoMap?.get("fast") as? Boolean) + ?: (if (serviceTier != null) serviceTier == "priority" else null) // B8 (Jun 20 2026, kanban t_session_resume): do NOT reload // cached messages here — switchSession() already did so before @@ -1059,6 +1072,8 @@ class ChatViewModel( } else { reasoningEffort }, + fastMode = fastFlag ?: false, + isFastModeChanging = false, terminalBackend = terminalBackend ?: it.terminalBackend, ) } @@ -1198,6 +1213,10 @@ class ChatViewModel( return } + if (method == WsMethods.CONFIG_SET) { + modelSwitchDelegate.handleConfigSetError(id, error) + } + if (method == WsMethods.SESSION_CREATE) { val pending = pendingInitialPrompt pendingInitialPrompt = null @@ -2221,6 +2240,8 @@ class ChatViewModel( fun setReasoningLevel(level: String?) = modelSwitchDelegate.setReasoningLevel(level) + fun toggleFastMode() = modelSwitchDelegate.toggleFastMode() + fun getModelCapabilities( providerSlug: String, modelName: String, @@ -2471,6 +2492,8 @@ class ChatViewModel( currentSessionModel = null, currentModelCapabilities = null, reasoningLevel = null, + fastMode = false, + isFastModeChanging = false, terminalBackend = null, usedContextTokens = null, fullContextTokens = null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 7e02eb86..0f6c139a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -103,6 +103,10 @@ fun ChatInputBar( onReasoningTap: (String?) -> Unit = {}, canDisableReasoning: Boolean? = null, supportsReasoning: Boolean? = null, + fastMode: Boolean = false, + fastSupported: Boolean = false, + isFastModeChanging: Boolean = false, + onToggleFastMode: () -> Unit = {}, ) { // Allow sending while the agent is mid-turn or awaiting approval: the // gateway's prompt.submit busy-input policy queues it as the next turn @@ -377,6 +381,10 @@ fun ChatInputBar( modifier = Modifier.testTag("chat_composer_toolbar"), canDisableReasoning = canDisableReasoning, supportsReasoning = supportsReasoning, + fastMode = fastMode, + fastSupported = fastSupported, + isFastModeChanging = isFastModeChanging, + onToggleFastMode = onToggleFastMode, ) // Attachment dropdown (anchored to the attach button in ComposerToolbar) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index 8e161627..5a0828ae 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -3,16 +3,20 @@ package com.m57.hermescontrol.ui.chat.components import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilterChip @@ -21,6 +25,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -29,8 +35,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R @@ -60,6 +70,10 @@ fun ComposerToolbar( modifier: Modifier = Modifier, canDisableReasoning: Boolean? = null, supportsReasoning: Boolean? = null, + fastMode: Boolean = false, + fastSupported: Boolean = false, + isFastModeChanging: Boolean = false, + onToggleFastMode: () -> Unit = {}, ) { var showReasoningMenu by remember { mutableStateOf(false) } val reasoningDisabledForModel = supportsReasoning == false @@ -109,24 +123,44 @@ fun ComposerToolbar( .testTag("model_chip"), ) - // Reasoning chip with dropdown menu (right side, next to mic) + // Reasoning & Generation controls with dropdown menu (Option 3) Box { FilterChip( - selected = reasoningLevel != null, + selected = reasoningLevel != null || fastMode, onClick = { showReasoningMenu = true }, - enabled = !reasoningDisabledForModel, + enabled = isConnected, label = { - Text( - text = - if (reasoningDisabledForModel) { - "No reasoning" - } else { - buildReasoningLabel(reasoningLevel) - }, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (fastMode) { + Icon( + imageVector = Icons.Filled.Bolt, + contentDescription = stringResource(R.string.chat_fast_mode_label), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp), + ) + } + Text( + text = + if (reasoningDisabledForModel) { + if (fastMode) stringResource(R.string.chat_fast_mode_label) else "No reasoning" + } else { + val rLabel = buildReasoningLabel(reasoningLevel) + if (fastMode) { + "${stringResource( + R.string.chat_fast_mode_label, + )} · $rLabel" + } else { + rLabel + } + }, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } }, modifier = Modifier @@ -137,11 +171,74 @@ fun ComposerToolbar( DropdownMenu( expanded = showReasoningMenu, onDismissRequest = { showReasoningMenu = false }, + modifier = Modifier.widthIn(min = 220.dp), ) { + // ── Fast Mode Toggle Item ── + val fastAvailable = isConnected && fastSupported + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Filled.Bolt, + contentDescription = null, + tint = + if (fastMode) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(20.dp), + ) + }, + text = { + Column { + Text( + text = stringResource(R.string.chat_fast_mode_label), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal, + ) + if (!fastSupported) { + Text( + text = stringResource(R.string.chat_fast_mode_unavailable), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + }, + trailingIcon = { + if (isFastModeChanging) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Switch( + checked = fastMode, + onCheckedChange = null, // MenuItem click owns the trigger + enabled = fastAvailable && !isFastModeChanging, + modifier = + Modifier + .scale(0.85f) + .testTag("fast_mode_switch"), + ) + } + }, + onClick = { + if (fastAvailable && !isFastModeChanging) { + onToggleFastMode() + } + }, + enabled = fastAvailable && !isFastModeChanging, + ) + + HorizontalDivider() + Text( - text = "Reasoning", + text = "REASONING", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), ) if (canDisable == false) { @@ -160,7 +257,6 @@ fun ComposerToolbar( modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), ) } - HorizontalDivider() val allLevels = listOf( "none" to "None", diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 2dce4f60..1ba136d5 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1031,6 +1031,9 @@ ينتهي في: %1$s البحث في القنوات… إرفاق ملف +سريع +الوضع السريع +الوضع السريع غير مدعوم لهذا النموذج حفظ %1$s تبديل النموذج (لهذه المحادثة) مسح القيمة diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ba813670..e48b33cc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -883,4 +883,7 @@ 취소됨 실패 %1$.1f초 + 빠름 + 빠른 모드 + 이 모델에서는 빠른 모드를 사용할 수 없습니다 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index ebd07c23..fcbfd2ec 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1022,6 +1022,9 @@ 过期时间:%1$s 搜索渠道… 附加文件 + 快速 + 快速模式 + 此模型不支持快速模式 保存 %1$s 切换模型(当前对话) 清除值 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 995ed704..5ca033e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1158,6 +1158,9 @@ Expires: %1$s Search channels… Attach file + Fast + Fast mode + Fast mode unavailable for this model Save %1$s Switch model (this chat) Clear value diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt index 346af7c8..478fe04a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt @@ -40,7 +40,7 @@ class ChatModelSwitchDelegateTest { slug = "openai", name = "OpenAI", models = listOf("gpt-4o"), - capabilities = mapOf("gpt-4o" to ModelCapabilities(reasoning = true)), + capabilities = mapOf("gpt-4o" to ModelCapabilities(fast = true, reasoning = true)), ), ), ) @@ -138,4 +138,73 @@ class ChatModelSwitchDelegateTest { assertNull(uiState.value.modelSwitchConfirmMessage) assertEquals("anthropic/claude-3", uiState.value.currentSessionModel) } + + @Test + fun toggleFastMode_sendsConfigSetFast_andUpdatesOnAck() = + testScope.runTest { + delegate.preloadModelOptions() + advanceUntilIdle() + + // Switch to model with fast capability + delegate.sendSlashModel("openai", "gpt-4o") + advanceUntilIdle() + sentMethods.clear() + sentParams.clear() + + assertTrue(uiState.value.currentModelCapabilities?.fast == true) + assertFalse(uiState.value.fastMode) + assertFalse(uiState.value.isFastModeChanging) + + delegate.toggleFastMode() + advanceUntilIdle() + assertTrue(uiState.value.isFastModeChanging) + assertFalse(uiState.value.fastMode) // Still unconfirmed! + assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods) + assertEquals("fast", sentParams.first()["key"]) + assertEquals("fast", sentParams.first()["value"]) + assertEquals("runtime-1", sentParams.first()["session_id"]) + + // Backend acknowledges + delegate.handleConfigSetResult("req-1", mapOf("key" to "fast", "value" to "fast")) + assertTrue(uiState.value.fastMode) + assertFalse(uiState.value.isFastModeChanging) + + // Toggle off + delegate.toggleFastMode() + advanceUntilIdle() + assertTrue(uiState.value.isFastModeChanging) + assertEquals("normal", sentParams.last()["value"]) + + delegate.handleConfigSetResult("req-2", mapOf("key" to "fast", "value" to "normal")) + assertFalse(uiState.value.fastMode) + assertFalse(uiState.value.isFastModeChanging) + } + + @Test + fun toggleFastMode_whenRejectedByBackend_clearsChangingAndLatchesUnavailable() = + testScope.runTest { + delegate.preloadModelOptions() + advanceUntilIdle() + delegate.sendSlashModel("openai", "gpt-4o") + advanceUntilIdle() + sentMethods.clear() + sentParams.clear() + assertTrue(uiState.value.currentModelCapabilities?.fast == true) + + delegate.toggleFastMode() + advanceUntilIdle() + assertTrue(uiState.value.isFastModeChanging) + + // Backend returns error indicating fast mode not available + delegate.handleConfigSetError("req-1", mapOf("message" to "fast mode is not available for this model")) + assertFalse(uiState.value.isFastModeChanging) + assertFalse(uiState.value.fastMode) + assertEquals(false, uiState.value.currentModelCapabilities?.fast) + + // Further toggle attempts are no-ops + val sentCount = sentMethods.size + delegate.toggleFastMode() + advanceUntilIdle() + assertEquals(sentCount, sentMethods.size) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 8bc8a174..ca845d81 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -2951,6 +2951,92 @@ class ChatViewModelTest { assertEquals("nous/tencent/hy3:free", viewModel.uiState.value.currentSessionModel) } + @Test + fun testSessionInfo_hydratesFastModeState() = + runTest { + stubEmptySessionRests("session-a") + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.fastMode) + + // Fast = true in session.info + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("fast" to true, "service_tier" to "priority"), + ), + ) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.fastMode) + assertFalse(viewModel.uiState.value.isFastModeChanging) + + // Fast = false / normal in session.info + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("fast" to false, "service_tier" to "normal"), + ), + ) + advanceUntilIdle() + assertFalse(viewModel.uiState.value.fastMode) + + // Partial session.info without fast/service_tier does NOT clear state + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("fast" to true), + ), + ) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.fastMode) + + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("terminal_backend" to "tmux"), + ), + ) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.fastMode) // preserved! + } + + @Test + fun testSessionResume_hydratesFastModeState() = + runTest { + stubEmptySessionRests("session-b") + val (viewModel, _) = createViewModelWithSession() + var resumeReqId = "" + every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers { + val reqId = "resume-req-1" + resumeReqId = reqId + arg<((String) -> Unit)?>(2)?.invoke(reqId) + reqId + } + + viewModel.switchSession("session-b") + runCurrent() + + // Simulate SESSION_RESUME response with fast=true in info + mockEventsFlow.emit( + WsEvent.RpcResult( + id = resumeReqId, + result = + mapOf( + "session_id" to "sess-runtime-1", + "resumed" to "session-b", + "info" to + mapOf( + "model" to "gpt-4o", + "provider" to "openai", + "fast" to true, + "service_tier" to "priority", + ), + ), + ), + ) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.fastMode) + assertFalse(viewModel.uiState.value.isFastModeChanging) + } + @Test fun testResumeNotFound_recoversWithNewSession() = runTest { From 065c7fd75f5514fd069180b218dd88c64b7bdcc0 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:04:13 +0300 Subject: [PATCH 33/42] feat(chat): display token usage and tps in message footers (#1094) --- .../7.json | 110 ++++++++++++++++++ .../data/local/ChatMessageEntity.kt | 4 + .../data/local/ChatMessageMapper.kt | 4 + .../data/local/HermesDatabase.kt | 15 ++- .../data/model/SessionMessagesResponse.kt | 9 ++ .../data/model/UsageSnapshotResponse.kt | 3 + .../m57/hermescontrol/data/ws/EventParser.kt | 2 +- .../com/m57/hermescontrol/data/ws/WsEvent.kt | 2 + .../ui/bots/group/GroupChatModels.kt | 2 + .../bots/group/components/GroupMessageCard.kt | 59 ++++++++++ .../m57/hermescontrol/ui/chat/ChatBubble.kt | 38 ++++++ .../m57/hermescontrol/ui/chat/ChatMessage.kt | 4 + .../ui/chat/ChatServerMessageMapper.kt | 2 + .../hermescontrol/ui/chat/ChatViewModel.kt | 11 +- .../ui/chat/ChatWsEventReducer.kt | 18 +++ .../hermescontrol/ui/chat/TokenEstimator.kt | 55 +++++++++ .../chat/fullbleed/FullBleedAgentMessage.kt | 39 +++++++ app/src/main/res/values-ar/strings.xml | 2 + app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values-zh/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../data/local/ChatMessageMapperTest.kt | 8 ++ .../local/HermesDatabaseMigrationsTest.kt | 15 +++ .../ui/chat/TokenEstimatorTest.kt | 43 +++++++ 24 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/7.json create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/TokenEstimator.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/TokenEstimatorTest.kt diff --git a/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/7.json b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/7.json new file mode 100644 index 00000000..3f6766e7 --- /dev/null +++ b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/7.json @@ -0,0 +1,110 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "3deb0f3f202de9316222a02a0b5ae80e", + "entities": [ + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_call_id` TEXT NOT NULL, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, `display_kind` TEXT, `token_count` INTEGER, `tps` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reasoningText", + "columnName": "reasoning_text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toolName", + "columnName": "tool_name", + "affinity": "TEXT" + }, + { + "fieldPath": "toolCallId", + "columnName": "tool_call_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toolStatus", + "columnName": "tool_status", + "affinity": "TEXT" + }, + { + "fieldPath": "isStreaming", + "columnName": "is_streaming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayKind", + "columnName": "display_kind", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenCount", + "columnName": "token_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "tps", + "columnName": "tps", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_session_id_timestamp", + "unique": false, + "columnNames": [ + "session_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` ON `${TABLE_NAME}` (`session_id`, `timestamp`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3deb0f3f202de9316222a02a0b5ae80e')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt index dcfebf18..548e45b5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt @@ -42,4 +42,8 @@ data class ChatMessageEntity( val isStreaming: Boolean = false, @ColumnInfo(name = "display_kind") val displayKind: String? = null, + @ColumnInfo(name = "token_count") + val tokenCount: Int? = null, + @ColumnInfo(name = "tps") + val tps: Double? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt index a7f40f68..2600557b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt @@ -35,6 +35,8 @@ fun ChatMessageEntity.toUiModel(): ChatMessage = else -> null }, displayKind = displayKind, + tokenCount = tokenCount, + tps = tps, ) fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = @@ -50,4 +52,6 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = toolStatus = toolStatus?.name, isStreaming = isStreaming, displayKind = displayKind, + tokenCount = tokenCount, + tps = tps, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt index 48b4b922..ab12e8b5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt @@ -12,7 +12,7 @@ import java.io.File @Database( entities = [ChatMessageEntity::class], - version = 6, + version = 7, exportSchema = true, ) abstract class HermesDatabase : RoomDatabase() { @@ -67,6 +67,18 @@ abstract class HermesDatabase : RoomDatabase() { } } + val MIGRATION_6_7: Migration = + object : Migration(6, 7) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "ALTER TABLE `chat_messages` ADD COLUMN `token_count` INTEGER", + ) + connection.execSQL( + "ALTER TABLE `chat_messages` ADD COLUMN `tps` REAL", + ) + } + } + fun get(context: Context): HermesDatabase = instance ?: synchronized(this) { // SQLCipher can't open plaintext SQLite databases — if an old @@ -97,6 +109,7 @@ abstract class HermesDatabase : RoomDatabase() { MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, + MIGRATION_6_7, ).fallbackToDestructiveMigration(false) .build() .also { instance = it } diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index 7f2ca663..c05002a3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.longOrNull @Serializable @@ -50,10 +51,18 @@ data class SessionMessage( val display_kind: String? = null, /** Display-only metadata for the marker (e.g. delegation result counts). */ val display_metadata: JsonElement? = null, + /** Token count recorded by the backend. */ + val token_count: JsonElement? = null, ) { val timestampText: String? get() = (timestamp as? JsonPrimitive)?.content + val tokenCount: Int? + get() { + val prim = token_count as? JsonPrimitive ?: return null + return prim.intOrNull ?: prim.content.toIntOrNull() + } + val timestampEpochMs: Long? get() { val prim = timestamp as? JsonPrimitive ?: return null diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt index 4250dc1f..9897320f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt @@ -20,6 +20,8 @@ data class UsageSnapshotResponse( val contextMax: Long? = null, /** Cumulative session total tokens. */ val totalTokens: Long? = null, + /** Rolling tokens-per-second generation speed. */ + val avgTps: Double? = null, ) /** @@ -38,5 +40,6 @@ fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? { contextUsed = (usageMap["context_used"] as? Number)?.toLong(), contextMax = (usageMap["context_max"] as? Number)?.toLong(), totalTokens = (usageMap["total"] as? Number)?.toLong(), + avgTps = (usageMap["avg_tps"] as? Number)?.toDouble(), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 4a19bd98..f2d680b3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -87,7 +87,7 @@ object EventParser { "message.complete" -> { val text = payload?.get("text") as? String ?: "" val reasoning = payload?.get("reasoning") as? String - WsEvent.MessageComplete(text, sessionId, reasoning) + WsEvent.MessageComplete(text, sessionId, reasoning, rawPayload = payload) } "message.done" -> { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 21d61b6a..3282eef9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -61,6 +61,8 @@ sealed class WsEvent { val reasoning: String? = null, /** Stored session id captured before background disconnect clears the active mapping. */ val storedSessionId: String? = null, + /** Full raw payload map (including usage/avg_tps) emitted with message.complete. */ + val rawPayload: Map? = null, ) : WsEvent() data class MessageDone( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt index 95600291..fcada2ea 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt @@ -34,6 +34,8 @@ data class GroupChatMessage( val isSystem: Boolean = false, val isPass: Boolean = false, val toolCalls: List = emptyList(), + val tokenCount: Int? = null, + val tps: Double? = null, ) const val STOPPED_SYSTEM_TEXT = "Discussion stopped" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt index bc403a26..719adf32 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt @@ -49,6 +49,7 @@ import com.m57.hermescontrol.ui.bots.group.CAPPED_SYSTEM_TEXT import com.m57.hermescontrol.ui.bots.group.GroupChatMessage import com.m57.hermescontrol.ui.bots.group.STOPPED_SYSTEM_TEXT import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.TokenEstimator import com.m57.hermescontrol.ui.chat.formatTimestamp import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.util.BidiUtils @@ -195,6 +196,26 @@ fun GroupMessageCard( color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), style = MaterialTheme.typography.labelSmall, ) + val userTokens = + message.tokenCount ?: TokenEstimator.estimate(message.text).takeIf { it > 0 } + if (userTokens != null && userTokens > 0) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(userTokens), + ), + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + ) + } } } } @@ -304,6 +325,44 @@ fun GroupMessageCard( color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), style = MaterialTheme.typography.labelSmall, ) + val botTokens = + message.tokenCount ?: TokenEstimator.estimate(message.text).takeIf { it > 0 } + if (botTokens != null && botTokens > 0) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(botTokens), + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + ) + } + if (message.tps != null && message.tps > 0.0) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tps, + TokenEstimator.formatTps(message.tps), + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 779788d2..6668826a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -257,6 +257,44 @@ fun ChatBubble( color = userBubbleTextColor.copy(alpha = 0.6f), style = MaterialTheme.typography.labelSmall, ) + if (message.tokenCount != null && message.tokenCount > 0) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = userBubbleTextColor.copy(alpha = 0.4f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(message.tokenCount), + ), + color = userBubbleTextColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.testTag("bubble_token_count"), + ) + } + if (message.tps != null && message.tps > 0.0) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = userBubbleTextColor.copy(alpha = 0.4f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tps, + TokenEstimator.formatTps(message.tps), + ), + color = userBubbleTextColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.testTag("bubble_tps"), + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 55b49a1f..b7afe3d6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -79,6 +79,10 @@ data class ChatMessage( * When present, rendered at the bottom beside the copy button. */ val finishTimestamp: Long? = null, + /** Token count used by this message (persisted to SQLite). */ + val tokenCount: Int? = null, + /** Tokens per second generation speed (persisted to SQLite). */ + val tps: Double? = null, ) /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt index 82eeffd5..2ef7269b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt @@ -171,6 +171,7 @@ internal fun mapServerMessages( } } + val tokenCount = msg.tokenCount ?: TokenEstimator.estimate(finalContent).takeIf { it > 0 } mapped.add( ChatMessage( id = restId, @@ -182,6 +183,7 @@ internal fun mapServerMessages( timestamp = timestamp, isStreaming = false, displayKind = msg.display_kind, + tokenCount = tokenCount, ), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 3077478c..a671f7b0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -146,6 +146,8 @@ data class ChatUiState( // until the first successful session.usage fetch) — drives the // "compressed ×N" badge on the context chip. val compressionCount: Int? = null, + /** Rolling output tokens/sec over the last ~10 calls. */ + val latestTps: Double? = null, // Attachment state val pendingAttachments: List = emptyList(), /** One-shot composer recovery after an attachment is rejected before send. */ @@ -1298,6 +1300,7 @@ class ChatViewModel( role = MessageRole.USER, content = text, attachments = if (attachments.isNotEmpty()) attachments else null, + tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 }, ) // Update UI immediately @@ -1342,6 +1345,7 @@ class ChatViewModel( role = MessageRole.USER, content = text, attachments = if (attachments.isNotEmpty()) attachments else null, + tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 }, ) // Upload attachments then submit prompt @@ -1624,7 +1628,12 @@ class ChatViewModel( val displayContent = if (result is SlashResult.QueuePrompt) result.displayContent else command - val userMsg = ChatMessage(role = MessageRole.USER, content = displayContent) + val userMsg = + ChatMessage( + role = MessageRole.USER, + content = displayContent, + tokenCount = TokenEstimator.estimate(displayContent).takeIf { it > 0 }, + ) val sessionId = _uiState.value.currentSessionId _uiState.update { it.copy(messages = it.messages + userMsg) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index d8db9c15..4c2ad8eb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -221,6 +221,8 @@ object ChatWsEventReducer { streamingState.streamingMessage.copy( isStreaming = false, finishTimestamp = System.currentTimeMillis(), + tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 }, + tps = state.latestTps?.takeIf { it > 0.0 }, ) orphan = finalized state.copy( @@ -379,17 +381,25 @@ object ChatWsEventReducer { streamingState = streamingState.copy(streamingMessage = null), ) } + val rawUsage = event.rawPayload?.get("usage") as? Map<*, *> + val tpsFromPayload = (rawUsage?.get("avg_tps") as? Number)?.toDouble() + val tps = (tpsFromPayload ?: state.latestTps)?.takeIf { it > 0.0 } + val tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 } val msg = streaming?.copy( content = text, isStreaming = false, reasoningText = reasoning, finishTimestamp = System.currentTimeMillis(), + tokenCount = tokenCount, + tps = tps, ) ?: ChatMessage( role = MessageRole.ASSISTANT, content = text, reasoningText = reasoning, finishTimestamp = System.currentTimeMillis(), + tokenCount = tokenCount, + tps = tps, ) val effects = mutableListOf() val sid = state.currentSessionId @@ -435,11 +445,14 @@ object ChatWsEventReducer { } else { streaming.reasoningText } + val tokenCount = TokenEstimator.estimate(streaming.content).takeIf { it > 0 } val msg = streaming.copy( isStreaming = false, reasoningText = reasoning, finishTimestamp = System.currentTimeMillis(), + tokenCount = streaming.tokenCount ?: tokenCount, + tps = streaming.tps ?: state.latestTps?.takeIf { it > 0.0 }, ) val effects = mutableListOf() val sid = state.currentSessionId @@ -504,6 +517,8 @@ object ChatWsEventReducer { isStreaming = false, reasoningText = reasoning, finishTimestamp = System.currentTimeMillis(), + tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 }, + tps = state.latestTps?.takeIf { it > 0.0 }, ) orphanToPersist = finalized state.copy( @@ -962,6 +977,9 @@ object ChatWsEventReducer { if (snapshot.contextMax != null && snapshot.contextMax > 0L) { updatedState = updatedState.copy(fullContextTokens = snapshot.contextMax) } + if (snapshot.avgTps != null && snapshot.avgTps > 0.0) { + updatedState = updatedState.copy(latestTps = snapshot.avgTps) + } return ReducerResult(state = updatedState, streamingState = streamingState) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/TokenEstimator.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/TokenEstimator.kt new file mode 100644 index 00000000..884edcc1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/TokenEstimator.kt @@ -0,0 +1,55 @@ +package com.m57.hermescontrol.ui.chat + +import java.util.Locale + +/** + * Token count estimation and formatting utilities. + * + * The estimation algorithm matches the backend's `estimate_tokens_rough` + * in `agent/model_metadata.py`: + * - CJK ideographs, Hangul, Kana count as ~1 token each. + * - Non-CJK characters count as ceil(UTF-8 bytes / 4). + */ +object TokenEstimator { + fun estimate(text: String?): Int { + if (text.isNullOrEmpty()) return 0 + var dense = 0 + var nonDenseByteCount = 0 + for (i in 0 until text.length) { + val ch = text[i] + if (isCjkDense(ch)) { + dense++ + } else { + val code = ch.code + nonDenseByteCount += + when { + code <= 0x7F -> 1 + code <= 0x7FF -> 2 + code <= 0xFFFF -> 3 + else -> 4 + } + } + } + return dense + ((nonDenseByteCount + 3) / 4) + } + + private fun isCjkDense(ch: Char): Boolean { + val code = ch.code + return (code in 0x4E00..0x9FFF) || + (code in 0x3400..0x4DBF) || + (code in 0xAC00..0xD7AF) || + (code in 0x1100..0x11FF) || + (code in 0x3040..0x309F) || + (code in 0x30A0..0x30FF) + } + + fun formatTokenCount(tokens: Int): String = + when { + tokens >= 1_000_000 -> String.format(Locale.US, "%.1fM", tokens / 1_000_000.0) + tokens >= 10_000 -> String.format(Locale.US, "%.1fk", tokens / 1_000.0) + tokens >= 1_000 -> String.format(Locale.US, "%,d", tokens) + else -> tokens.toString() + } + + fun formatTps(tps: Double): String = Math.round(tps).toString() +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 8acdba59..f34a0fa5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -40,6 +40,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.InlineAttachment import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.TokenEstimator import com.m57.hermescontrol.ui.chat.components.ReasoningCard import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -171,6 +172,44 @@ internal fun FullBleedAgentMessage( color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.testTag("fullbleed_finish_time"), ) + if (message.tokenCount != null && message.tokenCount > 0) { + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(message.tokenCount), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.testTag("fullbleed_token_count"), + ) + } + if (message.tps != null && message.tps > 0.0) { + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tps, + TokenEstimator.formatTps(message.tps), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.testTag("fullbleed_tps"), + ) + } } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 1ba136d5..66e586fc 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -168,6 +168,8 @@ التمرير إلى الأسفل نسخ تعديل +%1$s رمز +%1$s رمز/ث إزالة المرفق تعذر التقاط الصورة الاتصال diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e48b33cc..074382d3 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -165,6 +165,8 @@ 맨 아래로 복사 편집 + %1$s 토큰 + %1$s t/s 첨부 제거 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index fcbfd2ec..4f941cc8 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -168,6 +168,8 @@ 滚动到底部 复制 编辑 + %1$s tok + %1$s t/s 移除附件 拍照失败 连接 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ca033e7..c5872bb3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -186,6 +186,8 @@ Scroll to bottom Copy Edit + %1$s tok + %1$s t/s Remove attachment diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt index 1498858f..bf254f79 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt @@ -23,6 +23,8 @@ class ChatMessageMapperTest { toolName = "search", toolStatus = "RUNNING", isStreaming = true, + tokenCount = 42, + tps = 55.5, ) val ui = entity.toUiModel() @@ -35,6 +37,8 @@ class ChatMessageMapperTest { assertEquals("search", ui.toolName) assertEquals(ToolStatus.RUNNING, ui.toolStatus) assertTrue(ui.isStreaming) + assertEquals(42, ui.tokenCount) + assertEquals(55.5, ui.tps ?: 0.0, 0.001) } @Test @@ -49,6 +53,8 @@ class ChatMessageMapperTest { toolName = null, toolStatus = null, isStreaming = false, + tokenCount = 100, + tps = 24.8, ) val entity = ui.toEntity("session-b") @@ -62,6 +68,8 @@ class ChatMessageMapperTest { assertNull(entity.toolName) assertNull(entity.toolStatus) assertFalse(entity.isStreaming) + assertEquals(100, entity.tokenCount) + assertEquals(24.8, entity.tps ?: 0.0, 0.001) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt index 35453938..63a290fa 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt @@ -70,4 +70,19 @@ class HermesDatabaseMigrationsTest { ) } } + + @Test + fun migration6to7_executesTokenCountAndTpsColumnAdditionOnSQLiteConnection() = + runBlocking { + val connection = mockk(relaxed = true) + HermesDatabase.MIGRATION_6_7.migrate(connection) + verify(exactly = 1) { + connection.execSQL( + "ALTER TABLE `chat_messages` ADD COLUMN `token_count` INTEGER", + ) + connection.execSQL( + "ALTER TABLE `chat_messages` ADD COLUMN `tps` REAL", + ) + } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/TokenEstimatorTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/TokenEstimatorTest.kt new file mode 100644 index 00000000..eea5f418 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/TokenEstimatorTest.kt @@ -0,0 +1,43 @@ +package com.m57.hermescontrol.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TokenEstimatorTest { + @Test + fun estimate_emptyOrNull_returnsZero() { + assertEquals(0, TokenEstimator.estimate(null)) + assertEquals(0, TokenEstimator.estimate("")) + } + + @Test + fun estimate_asciiText_usesByteDiv4() { + // "test" is 4 bytes -> (4 + 3) / 4 = 1 token + assertEquals(1, TokenEstimator.estimate("test")) + // 8 chars -> (8 + 3) / 4 = 2 tokens + assertEquals(2, TokenEstimator.estimate("testtest")) + } + + @Test + fun estimate_cjkText_countsDenseCharacters() { + // 3 CJK ideographs -> 3 tokens + assertEquals(3, TokenEstimator.estimate("你好世界".substring(0, 3))) + // 4 Korean Hangul -> 4 tokens + assertEquals(4, TokenEstimator.estimate("안녕하세요".substring(0, 4))) + } + + @Test + fun formatTokenCount_formatsCompactUnits() { + assertEquals("42", TokenEstimator.formatTokenCount(42)) + assertEquals("999", TokenEstimator.formatTokenCount(999)) + assertEquals("1,500", TokenEstimator.formatTokenCount(1500)) + assertEquals("12.5k", TokenEstimator.formatTokenCount(12500)) + assertEquals("1.2M", TokenEstimator.formatTokenCount(1200000)) + } + + @Test + fun formatTps_roundsProperly() { + assertEquals("42", TokenEstimator.formatTps(41.6)) + assertEquals("50", TokenEstimator.formatTps(50.2)) + } +} From 47b6955c9a6df76d1615874b263f38bfb9a2b95b Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:29:40 +0300 Subject: [PATCH 34/42] fix(chat): bind tool milestone dividers to message id and exclude synthetic rows (#1095) --- .../hermescontrol/ui/chat/ChatListEntries.kt | 57 ++++++++++++---- .../ui/chat/fullbleed/FullBleedChatList.kt | 17 +---- .../ui/chat/ChatListEntriesTest.kt | 66 +++++++++++++++++-- 3 files changed, 108 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt index 28c91831..ce074201 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt @@ -25,33 +25,66 @@ import androidx.compose.ui.unit.sp */ const val TOOL_CALL_DIVIDER_INTERVAL = 5 +private const val MAX_ITERATIONS_SYSTEM_MARKER = + "You've reached the maximum number of tool-calling iterations allowed." + /** - * Returns the message indices that complete a tool-call milestone — a [MessageRole.TOOL] + * A genuine user message that establishes a new turn boundary. + * Timeline markers (`displayKind != null`) and synthetic system rows + * (such as max-iterations nudges) ride with `role == MessageRole.USER` + * in storage but belong to the surrounding agent turn. + */ +internal fun ChatMessage.isUserTurnBoundary(): Boolean = + role == MessageRole.USER && + displayKind == null && + !content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) + +/** + * Returns the message IDs that complete a tool-call milestone — a [MessageRole.TOOL] * message whose per-turn tool-call count is a multiple of [TOOL_CALL_DIVIDER_INTERVAL] — * mapped to the per-turn count at that point. * - * The counter resets at each [MessageRole.USER] message (a new turn), so + * The counter resets at each genuine user turn boundary ([isUserTurnBoundary]), so * milestones read `5, 10, 15...` within a turn and restart at `5` on the next. */ -fun toolCallMilestones(messages: List): Map { - val milestones = mutableMapOf() +fun toolCallMilestones(messages: List): Map { + val milestones = mutableMapOf() var toolCount = 0 - messages.forEachIndexed { index, message -> - when (message.role) { - MessageRole.USER -> { + messages.forEach { message -> + when { + message.isUserTurnBoundary() -> { toolCount = 0 } - MessageRole.TOOL -> { + message.role == MessageRole.TOOL -> { + toolCount += 1 + if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) { + milestones[message.id] = toolCount + } + } + } + } + return milestones +} + +/** + * Index-based milestone map for position-based callers and backwards compatibility. + */ +fun toolCallMilestoneIndices(messages: List): Map { + val milestones = mutableMapOf() + var toolCount = 0 + messages.forEachIndexed { index, message -> + when { + message.isUserTurnBoundary() -> { + toolCount = 0 + } + + message.role == MessageRole.TOOL -> { toolCount += 1 if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) { milestones[index] = toolCount } } - - else -> { - Unit - } } } return milestones diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index 5de6167d..692b2c5a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -85,7 +85,7 @@ fun FullBleedChatList( ) } } else { - val toolMilestones = toolCallMilestones(messages) + val toolMilestones = remember(messages) { toolCallMilestones(messages) } val turns = remember(messages, streamingMessage) { groupIntoTurnsWithStreaming(messages, streamingMessage) @@ -148,14 +148,12 @@ fun FullBleedChatList( } } - var entryIndex = 0 turns.forEach { turn -> when (turn) { is ChatTurn.User -> { // Eager captures: item lambda reads these at // composition time (lazy), so capture now. val userMessage = turn.message - val milestone = toolMilestones[entryIndex] item(key = "user-${userMessage.id}") { Column(modifier = Modifier.padding(bottom = 12.dp)) { renderChatBubble( @@ -170,12 +168,8 @@ fun FullBleedChatList( openingAttachmentPath = openingAttachmentPath, onImageClick = onImageClick, ) - milestone?.let { count -> - ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) - } } } - entryIndex++ } is ChatTurn.Agent -> { @@ -210,7 +204,6 @@ fun FullBleedChatList( val hoistedReasoning = turnReasoning != null && proseMessage.id == turnReasoning.message.id - val milestone = toolMilestones[entryIndex] item(key = "prose-${proseMessage.id}") { Column(modifier = Modifier.padding(bottom = 12.dp)) { if (proseMessage.isStreaming && typingEffectEnabled) { @@ -248,18 +241,14 @@ fun FullBleedChatList( onImageClick = onImageClick, ) } - milestone?.let { count -> - ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) - } } } firstProseSeen = true - entryIndex++ } is AgentEntry.ToolRow -> { val toolMessage = entry.message - val milestone = toolMilestones[entryIndex] + val milestone = toolMilestones[toolMessage.id] item(key = "tool-${toolMessage.id}") { Column(modifier = Modifier.padding(bottom = 6.dp)) { FullBleedToolRow(toolMessage) @@ -268,7 +257,6 @@ fun FullBleedChatList( } } } - entryIndex++ } is AgentEntry.SystemEvent -> { @@ -288,7 +276,6 @@ fun FullBleedChatList( } } } - entryIndex++ } } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatListEntriesTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatListEntriesTest.kt index ba38c791..2f147964 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatListEntriesTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatListEntriesTest.kt @@ -31,13 +31,18 @@ class ChatListEntriesTest { @Test fun fifthToolCallIsAMilestone() { val messages = List(5) { toolMessage() } - assertEquals(mapOf(4 to 5), toolCallMilestones(messages)) + assertEquals(mapOf(messages[4].id to 5), toolCallMilestones(messages)) + assertEquals(mapOf(4 to 5), toolCallMilestoneIndices(messages)) } @Test fun milestoneAtEveryFifthToolCallWithinTurn() { val messages = List(12) { toolMessage() } - assertEquals(mapOf(4 to 5, 9 to 10), toolCallMilestones(messages)) + assertEquals( + mapOf(messages[4].id to 5, messages[9].id to 10), + toolCallMilestones(messages), + ) + assertEquals(mapOf(4 to 5, 9 to 10), toolCallMilestoneIndices(messages)) } @Test @@ -57,7 +62,56 @@ class ChatListEntriesTest { toolMessage(), // 10 toolMessage(), // 11 ← 5th tool call of turn 2 (not 10) ) - assertEquals(mapOf(5 to 5, 11 to 5), toolCallMilestones(messages)) + assertEquals( + mapOf(messages[5].id to 5, messages[11].id to 5), + toolCallMilestones(messages), + ) + assertEquals(mapOf(5 to 5, 11 to 5), toolCallMilestoneIndices(messages)) + } + + @Test + fun timelineMarkerDoesNotResetToolCounter() { + val marker = + ChatMessage( + role = MessageRole.USER, + content = "[System: The active model has changed to gpt-5]", + displayKind = "model_switch", + ) + val messages = + listOf( + userMessage(), + toolMessage(), + toolMessage(), + marker, + toolMessage(), + toolMessage(), + toolMessage(), // 5th tool call despite intervening marker + ) + assertEquals(mapOf(messages[6].id to 5), toolCallMilestones(messages)) + assertEquals(mapOf(6 to 5), toolCallMilestoneIndices(messages)) + } + + @Test + fun syntheticMaxIterationsNudgeDoesNotResetToolCounter() { + val nudge = + ChatMessage( + role = MessageRole.USER, + content = + "You've reached the maximum number of tool-calling iterations allowed. " + + "Please provide a summary.", + ) + val messages = + listOf( + userMessage(), + toolMessage(), + toolMessage(), + toolMessage(), + nudge, + toolMessage(), + toolMessage(), // 5th tool call despite intervening nudge + ) + assertEquals(mapOf(messages[6].id to 5), toolCallMilestones(messages)) + assertEquals(mapOf(6 to 5), toolCallMilestoneIndices(messages)) } @Test @@ -73,13 +127,15 @@ class ChatListEntriesTest { textMessage(), // 6 toolMessage(), // 7 ← 5th tool call ) - assertEquals(mapOf(7 to 5), toolCallMilestones(messages)) + assertEquals(mapOf(messages[7].id to 5), toolCallMilestones(messages)) + assertEquals(mapOf(7 to 5), toolCallMilestoneIndices(messages)) } @Test fun toolMessagesWithoutToolNameStillCount() { val messages = List(5) { ChatMessage(role = MessageRole.TOOL, content = "", toolName = null) } - assertEquals(mapOf(4 to 5), toolCallMilestones(messages)) + assertEquals(mapOf(messages[4].id to 5), toolCallMilestones(messages)) + assertEquals(mapOf(4 to 5), toolCallMilestoneIndices(messages)) } @Test From d774a63663ba8dded792d870584ca26b8bfb1cf7 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:32:52 +0300 Subject: [PATCH 35/42] feat(plugins): browse and install official plugins from catalog (#1088) (#1096) --- .../hermescontrol/data/model/PluginInfo.kt | 56 +++- .../data/remote/HermesApiService.kt | 4 + .../m57/hermescontrol/ui/common/DetailRows.kt | 21 ++ .../hermescontrol/ui/plugins/PluginsScreen.kt | 222 +++++++++----- .../ui/plugins/PluginsViewModel.kt | 103 +++++++ .../plugins/components/CatalogPluginsView.kt | 289 ++++++++++++++++++ app/src/main/res/values/strings.xml | 22 ++ .../ui/plugins/PluginsViewModelTest.kt | 194 ++++++++++++ 8 files changed, 840 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/plugins/components/CatalogPluginsView.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt index 1ca609bd..5ba18ac5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt @@ -80,9 +80,63 @@ data class PluginsHubResponse( @Serializable data class AgentPluginInstallBody( - val identifier: String, + val identifier: String = "", val force: Boolean = false, val enable: Boolean = true, + @SerialName("catalog_name") val catalogName: String? = null, + val ref: String? = null, +) + +@Serializable +data class PluginCatalogCapabilities( + @SerialName("provides_tools") val providesTools: List = emptyList(), + @SerialName("provides_hooks") val providesHooks: List = emptyList(), + @SerialName("provides_middleware") val providesMiddleware: List = emptyList(), + @SerialName("requires_env") val requiresEnv: List = emptyList(), +) + +@Serializable +data class PluginCatalogRemovedEntry( + val name: String? = null, + val repo: String? = null, + val reason: String? = null, + val date: String? = null, +) + +@Serializable +data class PluginCatalogEntry( + val name: String, + @SerialName("catalog_name") val catalogName: String? = null, + val repo: String? = null, + val sha: String? = null, + @SerialName("sha_short") val shaShort: String? = null, + val description: String? = null, + val maintainer: String? = null, + val tier: String? = null, + val category: String? = null, + @SerialName("requires_hermes") val requiresHermes: String? = null, + val subdir: String? = null, + @SerialName("docs_url") val docsUrl: String? = null, + val platforms: List = emptyList(), + val capabilities: PluginCatalogCapabilities? = null, + @SerialName("capability_summary") val capabilitySummary: String? = null, + val installed: Boolean = false, + @SerialName("installed_sha") val installedSha: String? = null, + @SerialName("update_available") val updateAvailable: Boolean = false, + @SerialName("runtime_status") val runtimeStatus: String? = null, +) { + val displayName: String + get() = catalogName?.takeIf { it.isNotBlank() } ?: name + + val isOfficial: Boolean + get() = tier?.equals("official", ignoreCase = true) == true +} + +@Serializable +data class PluginCatalogResponse( + val entries: List = emptyList(), + val removed: List = emptyList(), + @SerialName("generated_at") val generatedAt: String? = null, ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 90745c64..88abd60a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -80,6 +80,7 @@ import com.m57.hermescontrol.data.model.OAuthSubmitResponse import com.m57.hermescontrol.data.model.PairingApproveRequest import com.m57.hermescontrol.data.model.PairingResponse import com.m57.hermescontrol.data.model.PairingRevokeRequest +import com.m57.hermescontrol.data.model.PluginCatalogResponse import com.m57.hermescontrol.data.model.PluginProvidersPutRequest import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.PortalResponse @@ -625,6 +626,9 @@ interface HermesApiService { @GET("api/dashboard/plugins/hub") suspend fun getPlugins(): Response + @GET("api/dashboard/plugins/catalog") + suspend fun getPluginCatalog(): Response + @POST("api/dashboard/agent-plugins/install") suspend fun installPlugin( @Body body: AgentPluginInstallBody, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt index f1e82343..c5763b3b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.HubSkill import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.Toolset @@ -68,6 +69,26 @@ fun PluginInfo.toDetailRows(): List = DetailRow(stringResource(R.string.detail_dialog_description), description), ) +@Composable +fun PluginCatalogEntry.toDetailRows(): List = + listOfNotNull( + tier?.let { DetailRow(stringResource(R.string.detail_dialog_tier), it) }, + maintainer?.let { DetailRow(stringResource(R.string.detail_dialog_maintainer), it) }, + category?.let { DetailRow(stringResource(R.string.detail_dialog_category), it) }, + repo?.let { DetailRow(stringResource(R.string.detail_dialog_repository), it) }, + shaShort?.let { DetailRow(stringResource(R.string.detail_dialog_commit), it) }, + requiresHermes?.takeIf { it.isNotBlank() }?.let { + DetailRow(stringResource(R.string.detail_dialog_requires_hermes), it) + }, + docsUrl?.takeIf { it.isNotBlank() }?.let { + DetailRow(stringResource(R.string.detail_dialog_docs), it) + }, + capabilitySummary?.takeIf { it.isNotBlank() }?.let { + DetailRow(stringResource(R.string.detail_dialog_capabilities), it) + }, + description?.let { DetailRow(stringResource(R.string.detail_dialog_description), it) }, + ) + @Composable fun Toolset.toDetailRows(): List = listOf( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index e62e5f86..9b391987 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -29,6 +29,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -47,6 +50,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.MemoryProviderDetailKey import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.common.DetailDialog @@ -61,6 +65,7 @@ import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -73,6 +78,7 @@ fun PluginsScreen( var query by remember { mutableStateOf("") } var showDetail by remember { mutableStateOf(null) } + var showCatalogDetail by remember { mutableStateOf(null) } val filteredPlugins = remember(query, state.plugins) { @@ -82,6 +88,29 @@ fun PluginsScreen( } } + val filteredCatalogEntries = + remember(state.catalogEntries, state.catalogQuery, state.catalogTierFilter) { + state.catalogEntries.filter { entry -> + val matchesQuery = + state.catalogQuery.isBlank() || + entry.displayName.contains(state.catalogQuery, ignoreCase = true) || + entry.description?.contains(state.catalogQuery, ignoreCase = true) == true || + entry.maintainer?.contains(state.catalogQuery, ignoreCase = true) == true || + entry.capabilities?.providesTools?.any { + it.contains( + state.catalogQuery, + ignoreCase = true, + ) + } == true + + val matchesTier = + state.catalogTierFilter == null || + entry.tier?.equals(state.catalogTierFilter, ignoreCase = true) == true + + matchesQuery && matchesTier + } + } + LaunchedEffect(Unit) { viewModel.loadPlugins() } @@ -120,85 +149,130 @@ fun PluginsScreen( HermesScaffold( title = { Text(stringResource(R.string.screen_plugins)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, - isRefreshing = state.isLoading, - onRefresh = { viewModel.loadPlugins() }, - ) { paddingValues -> - when { - state.isLoading && state.plugins.isEmpty() -> { - SkeletonListState(modifier = Modifier.padding(paddingValues)) + isRefreshing = if (state.selectedTab == PluginsTab.INSTALLED) state.isLoading else state.isCatalogLoading, + onRefresh = { + if (state.selectedTab == PluginsTab.INSTALLED) { + viewModel.loadPlugins() + } else { + viewModel.loadCatalog(isRefresh = true) } - - state.errorMessage != null -> { - ErrorState( - message = state.errorMessage ?: "", - onRetry = { viewModel.loadPlugins() }, - modifier = Modifier.padding(paddingValues), - ) - } - - state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> { - EmptyState( - title = stringResource(R.string.plugins_empty_title), - subtitle = stringResource(R.string.plugins_empty_desc), - onAction = { viewModel.loadPlugins() }, - actionLabel = stringResource(R.string.content_desc_refresh), - modifier = Modifier.padding(paddingValues), - ) - } - - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listContentPadding, - verticalArrangement = listItemSpacing, + }, + ) { _ -> + Column(modifier = Modifier.fillMaxSize()) { + SingleChoiceSegmentedButtonRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + SegmentedButton( + selected = state.selectedTab == PluginsTab.INSTALLED, + onClick = { viewModel.setTab(PluginsTab.INSTALLED) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), ) { - // Provider selection section - if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) { - item(key = "providers") { - ProviderSelectionSection(state, viewModel) + Text(stringResource(R.string.plugins_tab_installed)) + } + SegmentedButton( + selected = state.selectedTab == PluginsTab.CATALOG, + onClick = { viewModel.setTab(PluginsTab.CATALOG) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) { + Text(stringResource(R.string.plugins_tab_catalog)) + } + } + + when (state.selectedTab) { + PluginsTab.INSTALLED -> { + when { + state.isLoading && state.plugins.isEmpty() -> { + SkeletonListState() } - } - // Install section - item(key = "install") { - InstallSection(state, viewModel) - } - - // Search bar - item(key = "search") { - SearchBar( - query = query, - onQueryChange = { query = it }, - placeholder = stringResource(R.string.plugins_search_placeholder), - ) - } - - // Plugin list - items(filteredPlugins, key = { it.name }) { plugin -> - PluginCard( - plugin = plugin, - state = state, - viewModel = viewModel, - onClick = { showDetail = plugin }, - ) - } - - // Orphan dashboard plugins section - if (state.orphanPlugins.isNotEmpty()) { - item(key = "orphan-header") { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.plugins_orphan_heading), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp), + state.errorMessage != null && state.plugins.isEmpty() -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadPlugins() }, ) } - items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin -> - OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin }) + + state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> { + EmptyState( + title = stringResource(R.string.plugins_empty_title), + subtitle = stringResource(R.string.plugins_empty_desc), + onAction = { viewModel.loadPlugins() }, + actionLabel = stringResource(R.string.content_desc_refresh), + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + // Provider selection section + if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) { + item(key = "providers") { + ProviderSelectionSection(state, viewModel) + } + } + + // Install section + item(key = "install") { + InstallSection(state, viewModel) + } + + // Search bar + item(key = "search") { + SearchBar( + query = query, + onQueryChange = { query = it }, + placeholder = stringResource(R.string.plugins_search_placeholder), + ) + } + + // Plugin list + items(filteredPlugins, key = { it.name }) { plugin -> + PluginCard( + plugin = plugin, + state = state, + viewModel = viewModel, + onClick = { showDetail = plugin }, + ) + } + + // Orphan dashboard plugins section + if (state.orphanPlugins.isNotEmpty()) { + item(key = "orphan-header") { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.plugins_orphan_heading), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin -> + OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin }) + } + } + } } } } + + PluginsTab.CATALOG -> { + CatalogPluginsView( + state = state, + filteredEntries = filteredCatalogEntries, + onQueryChange = viewModel::setCatalogQuery, + onTierFilterChange = viewModel::setCatalogTierFilter, + onInstall = { viewModel.installCatalogPlugin(it) }, + onUpdate = { viewModel.installCatalogPlugin(it, force = true) }, + onShowDetail = { showCatalogDetail = it }, + onRetry = { viewModel.loadCatalog(isRefresh = true) }, + ) + } } } } @@ -210,6 +284,14 @@ fun PluginsScreen( onDismiss = { showDetail = null }, ) } + + showCatalogDetail?.let { entry -> + DetailDialog( + title = entry.displayName, + rows = entry.toDetailRows(), + onDismiss = { showCatalogDetail = null }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index 90d92114..d61507bb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.plugins import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.AgentPluginInstallBody +import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginProvidersPutRequest import com.m57.hermescontrol.data.model.ProviderOption @@ -9,6 +11,7 @@ import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.ToastHost +import com.m57.hermescontrol.ui.common.safeLaunchAction import com.m57.hermescontrol.ui.common.safeLaunchLoad import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -18,7 +21,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +enum class PluginsTab { + INSTALLED, + CATALOG, +} + data class PluginsUiState( + val selectedTab: PluginsTab = PluginsTab.INSTALLED, val isLoading: Boolean = false, val plugins: List = emptyList(), val orphanPlugins: List = emptyList(), @@ -41,6 +50,13 @@ data class PluginsUiState( val removeConfirmPlugin: String? = null, // Rescan val rescanBusy: Boolean = false, + // Catalog state + val catalogEntries: List = emptyList(), + val isCatalogLoading: Boolean = false, + val catalogErrorMessage: String? = null, + val catalogInstallingName: String? = null, + val catalogQuery: String = "", + val catalogTierFilter: String? = null, ) { /** Built-in memory provider sentinel — empty string means "use config defaults" */ val isMemoryBuiltin: Boolean @@ -382,6 +398,93 @@ class PluginsViewModel : _uiState.update { if (it.rowBusy == name) it.copy(rowBusy = null) else it } } + fun setTab(tab: PluginsTab) { + _uiState.update { it.copy(selectedTab = tab) } + if (tab == PluginsTab.CATALOG && _uiState.value.catalogEntries.isEmpty()) { + loadCatalog() + } + } + + fun loadCatalog(isRefresh: Boolean = false) { + safeLaunchLoad( + apiCall = { safeApiCall { ApiClient.hermesApi.getPluginCatalog() } }, + onStart = { + _uiState.update { + it.copy( + isCatalogLoading = true, + catalogErrorMessage = if (isRefresh) it.catalogErrorMessage else null, + ) + } + }, + onSuccess = { response -> + _uiState.update { + it.copy( + isCatalogLoading = false, + catalogEntries = response.entries, + catalogErrorMessage = null, + ) + } + }, + onError = { errorMsg -> + _uiState.update { + it.copy( + isCatalogLoading = false, + catalogErrorMessage = "Failed to load catalog: $errorMsg", + ) + } + }, + ) + } + + fun setCatalogQuery(query: String) { + _uiState.update { it.copy(catalogQuery = query) } + } + + fun setCatalogTierFilter(tier: String?) { + _uiState.update { it.copy(catalogTierFilter = tier) } + } + + fun installCatalogPlugin( + entry: PluginCatalogEntry, + force: Boolean = false, + ) { + val catalogName = entry.name + safeLaunchAction( + onStart = { _uiState.update { it.copy(catalogInstallingName = catalogName) } }, + apiCall = { + safeApiCall { + ApiClient.hermesApi.installPlugin( + AgentPluginInstallBody( + identifier = entry.repo ?: "", + catalogName = catalogName, + force = force, + enable = true, + ), + ) + } + }, + onSuccess = { + _uiState.update { + it.copy( + toastMessage = "Plugin \"${entry.displayName}\" installed successfully", + ) + } + loadPlugins() + loadCatalog(isRefresh = true) + }, + onError = { errorMsg -> + _uiState.update { + it.copy( + toastMessage = "Failed to install \"${entry.displayName}\": $errorMsg", + ) + } + }, + onComplete = { + _uiState.update { it.copy(catalogInstallingName = null) } + }, + ) + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/CatalogPluginsView.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/CatalogPluginsView.kt new file mode 100644 index 00000000..30a1727e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/CatalogPluginsView.kt @@ -0,0 +1,289 @@ +package com.m57.hermescontrol.ui.plugins.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.InstallDesktop +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.PluginCatalogEntry +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.FilterChipRow +import com.m57.hermescontrol.ui.common.SearchBar +import com.m57.hermescontrol.ui.common.SkeletonListState +import com.m57.hermescontrol.ui.common.listContentPadding +import com.m57.hermescontrol.ui.common.listItemSpacing +import com.m57.hermescontrol.ui.plugins.PluginsUiState + +@Composable +fun CatalogPluginsView( + state: PluginsUiState, + filteredEntries: List, + onQueryChange: (String) -> Unit, + onTierFilterChange: (String?) -> Unit, + onInstall: (PluginCatalogEntry) -> Unit, + onUpdate: (PluginCatalogEntry) -> Unit, + onShowDetail: (PluginCatalogEntry) -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxSize()) { + SearchBar( + query = state.catalogQuery, + onQueryChange = onQueryChange, + placeholder = stringResource(R.string.plugins_catalog_search_placeholder), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + + FilterChipRow( + chips = listOf(null, "official", "community"), + selectedChip = state.catalogTierFilter, + onChipSelected = onTierFilterChange, + modifier = Modifier.padding(vertical = 2.dp), + chipLabel = { tier -> + Text( + text = + when (tier) { + null -> stringResource(R.string.plugins_catalog_filter_all) + "official" -> stringResource(R.string.plugins_catalog_filter_official) + "community" -> stringResource(R.string.plugins_catalog_filter_community) + else -> tier + }, + ) + }, + ) + + when { + state.isCatalogLoading && state.catalogEntries.isEmpty() -> { + SkeletonListState() + } + + state.catalogErrorMessage != null && state.catalogEntries.isEmpty() -> { + ErrorState( + message = state.catalogErrorMessage, + onRetry = onRetry, + ) + } + + filteredEntries.isEmpty() -> { + EmptyState( + title = stringResource(R.string.plugins_catalog_empty_title), + subtitle = stringResource(R.string.plugins_catalog_empty_desc), + onAction = onRetry, + actionLabel = stringResource(R.string.content_desc_refresh), + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + items(filteredEntries, key = { it.name }) { entry -> + CatalogPluginCard( + entry = entry, + isInstalling = state.catalogInstallingName == entry.name, + onInstall = { onInstall(entry) }, + onUpdate = { onUpdate(entry) }, + onClick = { onShowDetail(entry) }, + ) + } + } + } + } + } +} + +@Composable +fun CatalogPluginCard( + entry: PluginCatalogEntry, + isInstalling: Boolean, + onInstall: () -> Unit, + onUpdate: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val statusColors = LocalHermesStatusColors.current + + Card( + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Header row: Title + Maintainer on left, Tier badge on right + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.displayName, + style = MaterialTheme.typography.titleMedium, + ) + if (!entry.maintainer.isNullOrBlank()) { + Text( + text = stringResource(R.string.plugins_catalog_maintainer, entry.maintainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + val isOfficial = entry.isOfficial + Surface( + shape = RoundedCornerShape(12.dp), + color = + if (isOfficial) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + ) { + Text( + text = + if (isOfficial) { + stringResource(R.string.plugins_catalog_tier_official) + } else { + stringResource(R.string.plugins_catalog_tier_community) + }, + style = MaterialTheme.typography.labelSmall, + color = + if (isOfficial) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + // Description + entry.description?.let { desc -> + Text( + text = desc, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + // Bottom row: Status badge on left, Action button on right + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (entry.installed) { + Text( + text = stringResource(R.string.plugins_catalog_installed_badge), + style = MaterialTheme.typography.labelSmall, + color = statusColors.success, + ) + } + if (entry.updateAvailable) { + Text( + text = stringResource(R.string.plugins_catalog_update), + style = MaterialTheme.typography.labelSmall, + color = statusColors.warning, + ) + } + } + + if (entry.installed && entry.updateAvailable) { + OutlinedButton( + onClick = onUpdate, + enabled = !isInstalling, + ) { + if (isInstalling) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.plugins_catalog_update)) + } + } else if (entry.installed) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(vertical = 8.dp), + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = statusColors.success, + ) + Text( + text = stringResource(R.string.plugins_catalog_installed_badge), + style = MaterialTheme.typography.labelMedium, + color = statusColors.success, + ) + } + } else { + Button( + onClick = onInstall, + enabled = !isInstalling, + ) { + if (isInstalling) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } else { + Icon( + imageVector = Icons.Default.InstallDesktop, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.plugins_action_install)) + } + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5872bb3..c6e776b5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -796,6 +796,21 @@ Orphaned Dashboard Plugins Show in sidebar Hide from sidebar + Installed + Catalog + Search catalog plugins... + No catalog plugins found + No plugins match your filter or search query + Installing %1$s… + Plugin \"%1$s\" installed successfully + Update + Installed + All + Official + Community + by %1$s + Official + Community Category @@ -819,6 +834,13 @@ Enabled Disabled Trust Level + Tier + Maintainer + Repository + Commit SHA + Requires Hermes + Documentation + Capabilities Transport: %1$s diff --git a/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt new file mode 100644 index 00000000..010da388 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt @@ -0,0 +1,194 @@ +package com.m57.hermescontrol.ui.plugins + +import com.m57.hermescontrol.data.model.AgentPluginInstallBody +import com.m57.hermescontrol.data.model.PluginCatalogCapabilities +import com.m57.hermescontrol.data.model.PluginCatalogEntry +import com.m57.hermescontrol.data.model.PluginCatalogResponse +import com.m57.hermescontrol.data.model.PluginInfo +import com.m57.hermescontrol.data.model.PluginsHubResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class PluginsViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockApi: HermesApiService + + private val sampleCatalogEntry = + PluginCatalogEntry( + name = "snyk", + catalogName = "snyk", + repo = "https://github.com/NousResearch/hermes-plugin-snyk", + sha = "30e0adfeaa181190bc1f81cf434a94aa6a56015f", + shaShort = "30e0adf", + description = "Security scanning for dependencies and code.", + maintainer = "NousResearch", + tier = "official", + capabilities = + PluginCatalogCapabilities( + providesTools = listOf("snyk_test"), + ), + installed = false, + updateAvailable = false, + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockApi = mockk() + mockkObject(ApiClient) + every { ApiClient.hermesApi } returns mockApi + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `switch tab to CATALOG automatically triggers catalog load`() { + coEvery { mockApi.getPluginCatalog() } returns + Response.success(PluginCatalogResponse(entries = listOf(sampleCatalogEntry))) + + val viewModel = PluginsViewModel() + viewModel.setTab(PluginsTab.CATALOG) + + assertEquals(PluginsTab.CATALOG, viewModel.uiState.value.selectedTab) + assertTrue(viewModel.uiState.value.isCatalogLoading) + + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isCatalogLoading) + assertEquals(1, viewModel.uiState.value.catalogEntries.size) + assertEquals( + "snyk", + viewModel.uiState.value.catalogEntries + .first() + .name, + ) + assertTrue( + viewModel.uiState.value.catalogEntries + .first() + .isOfficial, + ) + assertNull(viewModel.uiState.value.catalogErrorMessage) + + coVerify(exactly = 1) { mockApi.getPluginCatalog() } + } + + @Test + fun `loadCatalog handles api error gracefully`() { + coEvery { mockApi.getPluginCatalog() } returns + Response.error(500, "Internal Server Error".toResponseBody()) + + val viewModel = PluginsViewModel() + viewModel.loadCatalog() + + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isCatalogLoading) + assertTrue( + viewModel.uiState.value.catalogEntries + .isEmpty(), + ) + assertTrue( + viewModel.uiState.value.catalogErrorMessage + ?.contains("500") == true, + ) + } + + @Test + fun `filter and query updates affect state`() { + val viewModel = PluginsViewModel() + viewModel.setCatalogQuery("security") + viewModel.setCatalogTierFilter("official") + + assertEquals("security", viewModel.uiState.value.catalogQuery) + assertEquals("official", viewModel.uiState.value.catalogTierFilter) + } + + @Test + fun `installCatalogPlugin executes install and refreshes lists`() { + coEvery { + mockApi.installPlugin( + AgentPluginInstallBody( + identifier = sampleCatalogEntry.repo ?: "", + catalogName = sampleCatalogEntry.name, + force = false, + enable = true, + ), + ) + } returns Response.success(Unit) + + coEvery { mockApi.getPlugins() } returns + Response.success(PluginsHubResponse(plugins = listOf(PluginInfo(name = "snyk", runtimeStatus = "enabled")))) + coEvery { mockApi.getPluginCatalog() } returns + Response.success( + PluginCatalogResponse( + entries = listOf(sampleCatalogEntry.copy(installed = true)), + ), + ) + + val viewModel = PluginsViewModel() + viewModel.installCatalogPlugin(sampleCatalogEntry) + + assertEquals("snyk", viewModel.uiState.value.catalogInstallingName) + + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(viewModel.uiState.value.catalogInstallingName) + assertEquals("Plugin \"snyk\" installed successfully", viewModel.uiState.value.toastMessage) + assertEquals(1, viewModel.uiState.value.plugins.size) + assertTrue( + viewModel.uiState.value.catalogEntries + .first() + .installed, + ) + + coVerify(exactly = 1) { + mockApi.installPlugin( + match { it.catalogName == "snyk" && it.enable && !it.force }, + ) + } + } + + @Test + fun `installCatalogPlugin failure sets toast error`() { + coEvery { + mockApi.installPlugin(any()) + } returns Response.error(400, "Clone failed".toResponseBody()) + + val viewModel = PluginsViewModel() + viewModel.installCatalogPlugin(sampleCatalogEntry) + + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(viewModel.uiState.value.catalogInstallingName) + assertTrue( + viewModel.uiState.value.toastMessage + ?.contains("Failed to install") == true, + ) + } +} From 69cd34d289d4999b9dc5e41f5595dc82a4b8c4e2 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:20:07 +0300 Subject: [PATCH 36/42] feat(vault): credential vault status and mid-turn auth prompt handling (#1090) (#1097) --- .../chat/components/VaultPromptCardsTest.kt | 120 ++++++ .../java/com/m57/hermescontrol/Navigation.kt | 6 + .../com/m57/hermescontrol/NavigationKeys.kt | 2 + .../hermescontrol/data/model/VaultModels.kt | 61 +++ .../m57/hermescontrol/data/ws/EventParser.kt | 37 ++ .../hermescontrol/data/ws/VaultRepository.kt | 91 ++++ .../com/m57/hermescontrol/data/ws/WsEvent.kt | 38 ++ .../m57/hermescontrol/data/ws/WsMethods.kt | 10 + .../notification/ChatNotificationService.kt | 7 + .../ui/chat/ChatCredentialPromptsDelegate.kt | 232 ++++++++++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 10 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 69 +++ .../ui/chat/ChatWsEventReducer.kt | 15 + .../ui/chat/components/VaultPromptCards.kt | 394 +++++++++++++++++ .../ui/chat/fullbleed/FullBleedChatList.kt | 47 ++ .../ui/settings/SettingsScreen.kt | 8 + .../ui/settings/SettingsSubPages.kt | 57 +++ .../ui/settings/SettingsVaultViewModel.kt | 163 +++++++ .../ui/settings/components/VaultSection.kt | 400 ++++++++++++++++++ app/src/main/res/values/strings.xml | 36 ++ .../hermescontrol/data/ws/EventParserTest.kt | 145 +++++++ .../chat/ChatCredentialPromptsDelegateTest.kt | 186 +++++--- .../ui/settings/SettingsVaultViewModelTest.kt | 143 +++++++ 23 files changed, 2224 insertions(+), 53 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCardsTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/VaultModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCards.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/settings/components/VaultSection.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModelTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCardsTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCardsTest.kt new file mode 100644 index 00000000..044a7a04 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCardsTest.kt @@ -0,0 +1,120 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.ui.chat.VaultCodePromptUi +import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi +import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class VaultPromptCardsTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testVaultUnlockCard_interaction() { + var confirmedPassword = "" + var dismissed = false + + composeTestRule.setContent { + VaultUnlockCard( + prompt = + VaultUnlockPromptUi( + requestId = "req-1", + sessionId = "sess-1", + backend = "onepassword", + displayName = "1Password", + ), + onConfirm = { confirmedPassword = it }, + onDismiss = { dismissed = true }, + ) + } + + composeTestRule.onNodeWithTag("vault_unlock_card").assertIsDisplayed() + composeTestRule.onNodeWithText("Unlock 1Password").assertIsDisplayed() + + composeTestRule.onNodeWithTag("vault_unlock_password_input").performTextInput("mypass123") + composeTestRule.onNodeWithTag("vault_unlock_confirm").performClick() + + assertEquals("mypass123", confirmedPassword) + + composeTestRule.onNodeWithTag("vault_unlock_dismiss").performClick() + assertEquals(true, dismissed) + } + + @Test + fun testVaultSaveLoginCard_interaction() { + var savedIdentifier = "" + var savedPassword = "" + var dismissed = false + + composeTestRule.setContent { + VaultSaveLoginCard( + prompt = + VaultSaveLoginPromptUi( + requestId = "req-save", + sessionId = "sess-1", + origin = "https://github.com/login", + site = "GitHub", + ), + onConfirm = { id, pass -> + savedIdentifier = id + savedPassword = pass + }, + onDismiss = { dismissed = true }, + ) + } + + composeTestRule.onNodeWithTag("vault_save_login_card").assertIsDisplayed() + composeTestRule.onNodeWithTag("vault_save_login_identifier_input").performTextInput("testuser@example.com") + composeTestRule.onNodeWithTag("vault_save_login_password_input").performTextInput("supersecret") + + composeTestRule.onNodeWithTag("vault_save_login_confirm").performClick() + assertEquals("testuser@example.com", savedIdentifier) + assertEquals("supersecret", savedPassword) + + composeTestRule.onNodeWithTag("vault_save_login_dismiss").performClick() + assertEquals(true, dismissed) + } + + @Test + fun testVaultCodeCard_interaction() { + var submittedCode = "" + var dismissed = false + + composeTestRule.setContent { + VaultCodeCard( + prompt = + VaultCodePromptUi( + requestId = "req-code", + sessionId = "sess-1", + site = "GitHub", + hint = "SMS verification to ***1234", + ), + onConfirm = { submittedCode = it }, + onDismiss = { dismissed = true }, + ) + } + + composeTestRule.onNodeWithTag("vault_code_card").assertIsDisplayed() + composeTestRule.onNodeWithText("SMS verification to ***1234").assertIsDisplayed() + + composeTestRule.onNodeWithTag("vault_code_input").performTextInput("654321") + composeTestRule.onNodeWithTag("vault_code_confirm").performClick() + assertEquals("654321", submittedCode) + + composeTestRule.onNodeWithTag("vault_code_dismiss").performClick() + assertEquals(true, dismissed) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index be5727ef..0f7d575d 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -62,6 +62,7 @@ import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage import com.m57.hermescontrol.ui.settings.SettingsChatPage import com.m57.hermescontrol.ui.settings.SettingsConnectionPage import com.m57.hermescontrol.ui.settings.SettingsLanguagePage +import com.m57.hermescontrol.ui.settings.SettingsVaultPage import com.m57.hermescontrol.ui.settings.SettingsViewModel import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen import kotlinx.coroutines.launch @@ -137,6 +138,11 @@ private fun appEntryProvider( viewModel = viewModel { SettingsViewModel() }, ) } + entry { + SettingsVaultPage( + onBack = { NavigationController.goBack() }, + ) + } entry { SettingsAboutPage( onBack = { NavigationController.goBack() }, diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 1934804c..776be2da 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -87,4 +87,6 @@ import kotlinx.serialization.Serializable @Serializable data object SettingsBehavior : NavKey +@Serializable data object SettingsVault : NavKey + @Serializable data object SettingsAbout : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/VaultModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/VaultModels.kt new file mode 100644 index 00000000..f03fc26b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/VaultModels.kt @@ -0,0 +1,61 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Models for the Credential Vault RPC surface (issue #1090). + * + * Backend: `tui_gateway/methods_vault.py`. + * Sources represent local encrypted vault or external managers (1Password, Bitwarden). + */ + +@Serializable +data class VaultSource( + val name: String, + @SerialName("display_name") val displayName: String = name, + val enabled: Boolean = false, + @SerialName("needs_unlock") val needsUnlock: Boolean = false, + val unlocked: Boolean = false, + val installed: Boolean = false, +) + +@Serializable +data class VaultSourcesResponse( + val sources: List = emptyList(), +) + +@Serializable +data class VaultItem( + val id: String, + val backend: String = "local", + val kind: String = "login", + val label: String = "", + val origin: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("identifier_type") val identifierType: String? = null, + val identifier: String? = null, + @SerialName("has_otp") val hasOtp: Boolean = false, +) + +@Serializable +data class VaultListResponse( + val items: List = emptyList(), +) + +@Serializable +data class VaultSourceSetResponse( + val name: String? = null, + val enabled: Boolean? = null, +) + +@Serializable +data class VaultUnlockResponse( + val name: String? = null, + val unlocked: Boolean? = null, +) + +@Serializable +data class VaultLockResponse( + val locked: Boolean? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index f2d680b3..22bdd752 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -341,6 +341,43 @@ object EventParser { WsEvent.SecretExpire(requestId, sessionId) } + // ── Credential Vault prompts (issue #1090) ────────────────── + "vault.unlock.request" -> { + val requestId = payload?.get("request_id") as? String + val backend = payload?.get("backend") as? String + val displayName = payload?.get("display_name") as? String + WsEvent.VaultUnlockRequest(requestId, sessionId, backend, displayName) + } + + "vault.unlock.expire" -> { + val requestId = payload?.get("request_id") as? String + WsEvent.VaultUnlockExpire(requestId, sessionId) + } + + "vault.save_login.request" -> { + val requestId = payload?.get("request_id") as? String + val origin = payload?.get("origin") as? String + val site = payload?.get("site") as? String + WsEvent.VaultSaveLoginRequest(requestId, sessionId, origin, site) + } + + "vault.save_login.expire" -> { + val requestId = payload?.get("request_id") as? String + WsEvent.VaultSaveLoginExpire(requestId, sessionId) + } + + "vault.code.request" -> { + val requestId = payload?.get("request_id") as? String + val site = payload?.get("site") as? String + val hint = payload?.get("hint") as? String + WsEvent.VaultCodeRequest(requestId, sessionId, site, hint) + } + + "vault.code.expire" -> { + val requestId = payload?.get("request_id") as? String + WsEvent.VaultCodeExpire(requestId, sessionId) + } + else -> { Log.w(TAG, "Unknown event type: $eventType") WsEvent.Unknown(rawJson) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt new file mode 100644 index 00000000..56d7a6cc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt @@ -0,0 +1,91 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.VaultItem +import com.m57.hermescontrol.data.model.VaultListResponse +import com.m57.hermescontrol.data.model.VaultLockResponse +import com.m57.hermescontrol.data.model.VaultSource +import com.m57.hermescontrol.data.model.VaultSourceSetResponse +import com.m57.hermescontrol.data.model.VaultSourcesResponse +import com.m57.hermescontrol.data.model.VaultUnlockResponse +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.serializer + +/** + * Repository for Credential Vault RPC operations over WebSocket (issue #1090). + */ +object VaultRepository { + private val json get() = OkHttpProvider.json + + @Suppress("UNCHECKED_CAST") + private inline fun decode(result: Any?): T? { + if (result == null) return null + val element: JsonElement = + when (result) { + is JsonElement -> result + is Map<*, *> -> anyToJsonElement(result) + is List<*> -> JsonArray(result.map { anyToJsonElement(it) }) + else -> JsonPrimitive(result.toString()) + } + return json.decodeFromJsonElement(serializer(), element) + } + + @Suppress("UNCHECKED_CAST") + private fun anyToJsonElement(value: Any?): JsonElement = + when (value) { + null -> JsonNull + is JsonElement -> value + is Map<*, *> -> JsonObject((value as Map).mapValues { (_, v) -> anyToJsonElement(v) }) + is List<*> -> JsonArray(value.map { anyToJsonElement(it) }) + is String -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + else -> JsonPrimitive(value.toString()) + } + + suspend fun getSources(): List { + val result = HermesWsClient.request(WsMethods.VAULT_SOURCES).await() + return decode(result)?.sources ?: emptyList() + } + + suspend fun setSourceEnabled( + name: String, + enabled: Boolean, + ): Boolean { + val result = + HermesWsClient + .request( + WsMethods.VAULT_SOURCE_SET, + mapOf("name" to name, "enabled" to enabled), + ).await() + return decode(result)?.enabled == enabled + } + + suspend fun unlockSource( + name: String, + password: String, + ): Boolean { + val result = + HermesWsClient + .request( + WsMethods.VAULT_UNLOCK, + mapOf("name" to name, "password" to password), + ).await() + return decode(result)?.unlocked == true + } + + suspend fun lockSource(name: String? = null): Boolean { + val params = if (name != null) mapOf("name" to name) else emptyMap() + val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await() + return decode(result)?.locked == true + } + + suspend fun listItems(): List { + val result = HermesWsClient.request(WsMethods.VAULT_LIST).await() + return decode(result)?.items ?: emptyList() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 3282eef9..94dd2b25 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -303,6 +303,44 @@ sealed class WsEvent { val sessionId: String?, ) : WsEvent() + // ── Credential Vault prompts (issue #1090) ────────────────────────── + + data class VaultUnlockRequest( + val requestId: String?, + val sessionId: String? = null, + val backend: String? = null, + val displayName: String? = null, + ) : WsEvent() + + data class VaultUnlockExpire( + val requestId: String?, + val sessionId: String? = null, + ) : WsEvent() + + data class VaultSaveLoginRequest( + val requestId: String?, + val sessionId: String? = null, + val origin: String? = null, + val site: String? = null, + ) : WsEvent() + + data class VaultSaveLoginExpire( + val requestId: String?, + val sessionId: String? = null, + ) : WsEvent() + + data class VaultCodeRequest( + val requestId: String?, + val sessionId: String? = null, + val site: String? = null, + val hint: String? = null, + ) : WsEvent() + + data class VaultCodeExpire( + val requestId: String?, + val sessionId: String? = null, + ) : WsEvent() + // ── Gateway-level errors ─────────────────────────────────────────── /** diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index e2d8a1da..586f146a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -43,6 +43,16 @@ object WsMethods { const val SUDO_RESPOND = "sudo.respond" const val SECRET_RESPOND = "secret.respond" + // ── Credential Vault (issue #1090) ──────────────────────────────────── + const val VAULT_UNLOCK_RESPOND = "vault.unlock.respond" + const val VAULT_SAVE_LOGIN_RESPOND = "vault.save_login.respond" + const val VAULT_CODE_RESPOND = "vault.code.respond" + const val VAULT_LIST = "vault.list" + const val VAULT_SOURCES = "vault.sources" + const val VAULT_SOURCE_SET = "vault.source.set" + const val VAULT_UNLOCK = "vault.unlock" + const val VAULT_LOCK = "vault.lock" + // ── Heartbeat & Latency (issue #1017) ────────────────────────────────── /** Ultra-lightweight liveness check and round-trip latency measurement. */ diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt index 5bfd9fda..ce7e79ab 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -140,6 +140,13 @@ class ChatNotificationService : Service() { showReplyNotification(preview.take(100), null) } + is WsEvent.VaultUnlockRequest, + is WsEvent.VaultSaveLoginRequest, + is WsEvent.VaultCodeRequest, + -> { + showReplyNotification(getString(R.string.notif_input_needed), null) + } + else -> {} } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt index 8b47c45d..fdaa51c0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put /** * Owns sudo and secret prompt state, timeouts, dismissal, and response RPCs. @@ -177,4 +179,234 @@ class ChatCredentialPromptsDelegate( ) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) } } } + + // ── Vault prompts (issue #1090) ─────────────────────────────────────── + + /** + * The agent needs to unlock a password manager (1Password, Bitwarden). + * Surface an interactive prompt card in chat and reply via vault.unlock.respond. + */ + fun handleVaultUnlockRequest(event: WsEvent.VaultUnlockRequest) { + uiState.update { + it.copy( + vaultUnlockPrompt = + VaultUnlockPromptUi( + requestId = event.requestId, + sessionId = event.sessionId, + backend = event.backend, + displayName = event.displayName, + ), + isAgentTyping = false, + ) + } + } + + fun handleVaultUnlockExpire(event: WsEvent.VaultUnlockExpire) { + uiState.update { state -> + val current = state.vaultUnlockPrompt ?: return@update state + if (event.requestId != null && current.requestId != null && + event.requestId != current.requestId + ) { + return@update state + } + state.copy(vaultUnlockPrompt = null) + } + } + + /** + * Cancel/dismiss → send empty password (keeps manager locked, unblocks turn). + */ + fun dismissVaultUnlock() { + val prompt = uiState.value.vaultUnlockPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId + uiState.update { it.copy(vaultUnlockPrompt = null) } + if (sessionId == null) return + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "password" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.VAULT_UNLOCK_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) } + } + } + + fun respondToVaultUnlock(password: String) { + val prompt = uiState.value.vaultUnlockPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return + if (password.isBlank()) return + + uiState.update { it.copy(vaultUnlockPrompt = null) } + + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "password" to password, + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.VAULT_UNLOCK_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) } + } + } + + /** + * The agent captured credentials on a login page and asks whether to store them in the vault. + */ + fun handleVaultSaveLoginRequest(event: WsEvent.VaultSaveLoginRequest) { + uiState.update { + it.copy( + vaultSaveLoginPrompt = + VaultSaveLoginPromptUi( + requestId = event.requestId, + sessionId = event.sessionId, + origin = event.origin, + site = event.site, + ), + isAgentTyping = false, + ) + } + } + + fun handleVaultSaveLoginExpire(event: WsEvent.VaultSaveLoginExpire) { + uiState.update { state -> + val current = state.vaultSaveLoginPrompt ?: return@update state + if (event.requestId != null && current.requestId != null && + event.requestId != current.requestId + ) { + return@update state + } + state.copy(vaultSaveLoginPrompt = null) + } + } + + /** + * Decline saving → send empty login (backend treats empty login as declined). + */ + fun dismissVaultSaveLogin() { + val prompt = uiState.value.vaultSaveLoginPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId + uiState.update { it.copy(vaultSaveLoginPrompt = null) } + if (sessionId == null) return + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "login" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.VAULT_SAVE_LOGIN_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) } + } + } + + fun respondToVaultSaveLogin( + identifier: String, + password: String, + ) { + val prompt = uiState.value.vaultSaveLoginPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return + if (identifier.isBlank() || password.isBlank()) return + + uiState.update { it.copy(vaultSaveLoginPrompt = null) } + + scope.launch(ioDispatcher) { + val loginJson = + buildJsonObject { + put("identifier", identifier) + put("password", password) + }.toString() + val params = + mutableMapOf( + "session_id" to sessionId, + "login" to loginJson, + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.VAULT_SAVE_LOGIN_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) } + } + } + + /** + * The site requested a 2FA/MFA verification code. + */ + fun handleVaultCodeRequest(event: WsEvent.VaultCodeRequest) { + uiState.update { + it.copy( + vaultCodePrompt = + VaultCodePromptUi( + requestId = event.requestId, + sessionId = event.sessionId, + site = event.site, + hint = event.hint, + ), + isAgentTyping = false, + ) + } + } + + fun handleVaultCodeExpire(event: WsEvent.VaultCodeExpire) { + uiState.update { state -> + val current = state.vaultCodePrompt ?: return@update state + if (event.requestId != null && current.requestId != null && + event.requestId != current.requestId + ) { + return@update state + } + state.copy(vaultCodePrompt = null) + } + } + + /** + * Skip entering code → send empty code. + */ + fun dismissVaultCode() { + val prompt = uiState.value.vaultCodePrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId + uiState.update { it.copy(vaultCodePrompt = null) } + if (sessionId == null) return + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "code" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.VAULT_CODE_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) } + } + } + + fun respondToVaultCode(code: String) { + val prompt = uiState.value.vaultCodePrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return + if (code.isBlank()) return + + uiState.update { it.copy(vaultCodePrompt = null) } + + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "code" to code, + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.VAULT_CODE_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) } + } + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 9067946a..606180d3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -3,7 +3,6 @@ package com.m57.hermescontrol.ui.chat import android.app.Activity import android.content.Intent import android.net.Uri -import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility @@ -549,6 +548,15 @@ fun ChatScreen( onRespondClarify = viewModel::respondToClarify, onRespondClarifyBatch = viewModel::respondToClarifyBatch, onDismissClarify = viewModel::dismissClarify, + vaultUnlockPrompt = state.vaultUnlockPrompt, + onRespondVaultUnlock = viewModel::respondToVaultUnlock, + onDismissVaultUnlock = viewModel::dismissVaultUnlock, + vaultSaveLoginPrompt = state.vaultSaveLoginPrompt, + onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin, + onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin, + vaultCodePrompt = state.vaultCodePrompt, + onRespondVaultCode = viewModel::respondToVaultCode, + onDismissVaultCode = viewModel::dismissVaultCode, onSaveAttachment = onSaveAttachment, savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, openingAttachmentPath = state.openingAttachmentPath, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index a671f7b0..b0806a59 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -98,6 +98,10 @@ data class ChatUiState( // Sudo / secret prompts — surfaced as dialogs (issue #524) val sudoPrompt: SudoPromptUi? = null, val secretPrompt: SecretPromptUi? = null, + // Credential vault prompts — interactive prompt cards (issue #1090) + val vaultUnlockPrompt: VaultUnlockPromptUi? = null, + val vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null, + val vaultCodePrompt: VaultCodePromptUi? = null, val showSessionPicker: Boolean = false, // /update confirm dialog (issue #862) — the command is handled client-side val updateConfirmOpen: Boolean = false, @@ -251,6 +255,30 @@ data class SecretPromptUi( val prompt: String? = null, ) +/** Transient — not persisted. Holds a pending vault unlock request (issue #1090). */ +data class VaultUnlockPromptUi( + val requestId: String?, + val sessionId: String?, + val backend: String? = null, + val displayName: String? = null, +) + +/** Transient — not persisted. Holds a pending vault save login request (issue #1090). */ +data class VaultSaveLoginPromptUi( + val requestId: String?, + val sessionId: String?, + val origin: String? = null, + val site: String? = null, +) + +/** Transient — not persisted. Holds a pending vault 2FA/MFA code request (issue #1090). */ +data class VaultCodePromptUi( + val requestId: String?, + val sessionId: String?, + val site: String? = null, + val hint: String? = null, +) + /** * Token breakdown backing the context meter's detail sheet. All values are * cumulative lifetime token counts sourced from `GET /api/sessions/{id}` @@ -882,6 +910,30 @@ class ChatViewModel( credentialPromptsDelegate.handleSecretExpire(event) } + is WsEvent.VaultUnlockRequest -> { + credentialPromptsDelegate.handleVaultUnlockRequest(event) + } + + is WsEvent.VaultUnlockExpire -> { + credentialPromptsDelegate.handleVaultUnlockExpire(event) + } + + is WsEvent.VaultSaveLoginRequest -> { + credentialPromptsDelegate.handleVaultSaveLoginRequest(event) + } + + is WsEvent.VaultSaveLoginExpire -> { + credentialPromptsDelegate.handleVaultSaveLoginExpire(event) + } + + is WsEvent.VaultCodeRequest -> { + credentialPromptsDelegate.handleVaultCodeRequest(event) + } + + is WsEvent.VaultCodeExpire -> { + credentialPromptsDelegate.handleVaultCodeExpire(event) + } + is WsEvent.GatewayError -> { // Reducer already set errorMessage; no extra VM work needed. } @@ -3251,6 +3303,23 @@ class ChatViewModel( fun respondToSecret(value: String) = credentialPromptsDelegate.respondToSecret(value) + // ── Vault prompt flow (issue #1090) ────────────────────────────────── + + fun dismissVaultUnlock() = credentialPromptsDelegate.dismissVaultUnlock() + + fun respondToVaultUnlock(password: String) = credentialPromptsDelegate.respondToVaultUnlock(password) + + fun dismissVaultSaveLogin() = credentialPromptsDelegate.dismissVaultSaveLogin() + + fun respondToVaultSaveLogin( + identifier: String, + password: String, + ) = credentialPromptsDelegate.respondToVaultSaveLogin(identifier, password) + + fun dismissVaultCode() = credentialPromptsDelegate.dismissVaultCode() + + fun respondToVaultCode(code: String) = credentialPromptsDelegate.respondToVaultCode(code) + fun reconnect() { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 4c2ad8eb..28b71c24 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -75,6 +75,12 @@ object ChatWsEventReducer { is WsEvent.BtwComplete -> event.sessionId is WsEvent.SessionUsage -> event.sessionId is WsEvent.TranscriptResyncRequired -> event.sessionId + is WsEvent.VaultUnlockRequest -> event.sessionId + is WsEvent.VaultUnlockExpire -> event.sessionId + is WsEvent.VaultSaveLoginRequest -> event.sessionId + is WsEvent.VaultSaveLoginExpire -> event.sessionId + is WsEvent.VaultCodeRequest -> event.sessionId + is WsEvent.VaultCodeExpire -> event.sessionId else -> null } if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) { @@ -170,6 +176,15 @@ object ChatWsEventReducer { is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState) + // Vault prompts are handled by the ViewModel (issue #1090) + is WsEvent.VaultUnlockRequest, + is WsEvent.VaultUnlockExpire, + is WsEvent.VaultSaveLoginRequest, + is WsEvent.VaultSaveLoginExpire, + is WsEvent.VaultCodeRequest, + is WsEvent.VaultCodeExpire, + -> ReducerResult(state = state, streamingState = streamingState) + // ReactionEvent is handled by the ViewModel — purely cosmetic animation is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCards.kt new file mode 100644 index 00000000..8e93888e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCards.kt @@ -0,0 +1,394 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Pin +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.VaultCodePromptUi +import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi +import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi + +/** + * Interactive prompt cards for mid-turn Credential Vault operations (issue #1090). + * Rendered inline in chat (matching ClarifyBubble pattern) to avoid blocking dialog scrims. + */ + +@Composable +fun VaultUnlockCard( + prompt: VaultUnlockPromptUi, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var password by remember(prompt.requestId) { mutableStateOf("") } + var passwordVisible by remember(prompt.requestId) { mutableStateOf(false) } + + val displayName = prompt.displayName ?: prompt.backend ?: "Password Manager" + + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .testTag("vault_unlock_card"), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.vault_unlock_title, displayName), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Text( + text = stringResource(R.string.vault_unlock_desc, displayName), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.vault_unlock_password_label)) }, + modifier = + Modifier + .fillMaxWidth() + .testTag("vault_unlock_password_input"), + singleLine = true, + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = null, + ) + } + }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.testTag("vault_unlock_dismiss"), + ) { + Text(stringResource(R.string.vault_unlock_dismiss)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (password.isNotBlank()) { + onConfirm(password) + } + }, + enabled = password.isNotBlank(), + modifier = Modifier.testTag("vault_unlock_confirm"), + ) { + Text(stringResource(R.string.vault_unlock_action)) + } + } + } + } +} + +@Composable +fun VaultSaveLoginCard( + prompt: VaultSaveLoginPromptUi, + onConfirm: (identifier: String, password: String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var identifier by remember(prompt.requestId) { mutableStateOf("") } + var password by remember(prompt.requestId) { mutableStateOf("") } + var passwordVisible by remember(prompt.requestId) { mutableStateOf(false) } + + val siteLabel = prompt.site ?: prompt.origin ?: "this site" + + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .testTag("vault_save_login_card"), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.vault_save_login_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Text( + text = stringResource(R.string.vault_save_login_desc, siteLabel), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = identifier, + onValueChange = { identifier = it }, + label = { Text(stringResource(R.string.vault_save_login_identifier)) }, + modifier = + Modifier + .fillMaxWidth() + .testTag("vault_save_login_identifier_input"), + singleLine = true, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Email, + ), + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.vault_save_login_password)) }, + modifier = + Modifier + .fillMaxWidth() + .testTag("vault_save_login_password_input"), + singleLine = true, + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = null, + ) + } + }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.testTag("vault_save_login_dismiss"), + ) { + Text(stringResource(R.string.vault_save_login_dismiss)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (identifier.isNotBlank() && password.isNotBlank()) { + onConfirm(identifier.trim(), password) + } + }, + enabled = identifier.isNotBlank() && password.isNotBlank(), + modifier = Modifier.testTag("vault_save_login_confirm"), + ) { + Text(stringResource(R.string.vault_save_login_action)) + } + } + } + } +} + +@Composable +fun VaultCodeCard( + prompt: VaultCodePromptUi, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var code by remember(prompt.requestId) { mutableStateOf("") } + + val siteLabel = prompt.site ?: "site" + val desc = prompt.hint?.takeIf { it.isNotBlank() } ?: stringResource(R.string.vault_code_desc, siteLabel) + + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .testTag("vault_code_card"), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Pin, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.vault_code_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Text( + text = desc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = code, + onValueChange = { code = it }, + label = { Text(stringResource(R.string.vault_code_label)) }, + placeholder = { Text("123456") }, + modifier = + Modifier + .fillMaxWidth() + .testTag("vault_code_input"), + singleLine = true, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Number, + ), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.testTag("vault_code_dismiss"), + ) { + Text(stringResource(R.string.vault_code_dismiss)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (code.isNotBlank()) { + onConfirm(code.trim()) + } + }, + enabled = code.isNotBlank(), + modifier = Modifier.testTag("vault_code_confirm"), + ) { + Text(stringResource(R.string.vault_code_action)) + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index 692b2c5a..98a311ed 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -28,9 +28,15 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.ToolCallDivider +import com.m57.hermescontrol.ui.chat.VaultCodePromptUi +import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi +import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi import com.m57.hermescontrol.ui.chat.components.ChatScrollController import com.m57.hermescontrol.ui.chat.components.ClarifyBubble import com.m57.hermescontrol.ui.chat.components.ReasoningCard +import com.m57.hermescontrol.ui.chat.components.VaultCodeCard +import com.m57.hermescontrol.ui.chat.components.VaultSaveLoginCard +import com.m57.hermescontrol.ui.chat.components.VaultUnlockCard import com.m57.hermescontrol.ui.chat.toolCallMilestones import com.m57.hermescontrol.ui.common.EmptyState @@ -69,6 +75,15 @@ fun FullBleedChatList( onRespondClarify: ((String) -> Unit)? = null, onRespondClarifyBatch: ((Map) -> Unit)? = null, onDismissClarify: (() -> Unit)? = null, + vaultUnlockPrompt: VaultUnlockPromptUi? = null, + onRespondVaultUnlock: ((String) -> Unit)? = null, + onDismissVaultUnlock: (() -> Unit)? = null, + vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null, + onRespondVaultSaveLogin: ((String, String) -> Unit)? = null, + onDismissVaultSaveLogin: (() -> Unit)? = null, + vaultCodePrompt: VaultCodePromptUi? = null, + onRespondVaultCode: ((String) -> Unit)? = null, + onDismissVaultCode: (() -> Unit)? = null, onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {}, savingAttachmentPath: String? = null, openingAttachmentPath: String? = null, @@ -294,6 +309,38 @@ fun FullBleedChatList( ) } } + + if (vaultUnlockPrompt != null) { + item(key = "vault_unlock_card") { + VaultUnlockCard( + prompt = vaultUnlockPrompt, + onConfirm = { password -> onRespondVaultUnlock?.invoke(password) }, + onDismiss = { onDismissVaultUnlock?.invoke() }, + ) + } + } + + if (vaultSaveLoginPrompt != null) { + item(key = "vault_save_login_card") { + VaultSaveLoginCard( + prompt = vaultSaveLoginPrompt, + onConfirm = { identifier, password -> + onRespondVaultSaveLogin?.invoke(identifier, password) + }, + onDismiss = { onDismissVaultSaveLogin?.invoke() }, + ) + } + } + + if (vaultCodePrompt != null) { + item(key = "vault_code_card") { + VaultCodeCard( + prompt = vaultCodePrompt, + onConfirm = { code -> onRespondVaultCode?.invoke(code) }, + onDismiss = { onDismissVaultCode?.invoke() }, + ) + } + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index e1c532ce..67d8d42b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.material.icons.automirrored.filled.ListAlt import androidx.compose.material.icons.filled.ChatBubbleOutline import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.Card @@ -41,6 +42,7 @@ import com.m57.hermescontrol.SettingsBehavior import com.m57.hermescontrol.SettingsChat import com.m57.hermescontrol.SettingsConnection import com.m57.hermescontrol.SettingsLanguage +import com.m57.hermescontrol.SettingsVault import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon @@ -118,6 +120,12 @@ fun SettingsScreen( }, onClick = { NavigationController.navigateTo(SettingsBehavior) }, ), + SettingsRow( + icon = Icons.Filled.Lock, + label = stringResource(R.string.settings_sec_vault), + summary = stringResource(R.string.settings_summary_vault), + onClick = { NavigationController.navigateTo(SettingsVault) }, + ), SettingsRow( icon = Icons.Filled.Info, label = stringResource(R.string.settings_sec_about), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index 7e019895..db5a0e92 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -15,8 +15,11 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,6 +42,9 @@ import com.m57.hermescontrol.ui.settings.components.ConnectionSection import com.m57.hermescontrol.ui.settings.components.LanguageSection import com.m57.hermescontrol.ui.settings.components.TestConnectionButton import com.m57.hermescontrol.ui.settings.components.TestResultCard +import com.m57.hermescontrol.ui.settings.components.VaultItemsSection +import com.m57.hermescontrol.ui.settings.components.VaultSourcesSection +import com.m57.hermescontrol.ui.settings.components.VaultUnlockDialog /** * Drill-down sub-pages for Settings. Each is its own NavKey destination @@ -288,3 +294,54 @@ internal fun SettingsAboutPage( } } } + +@Composable +internal fun SettingsVaultPage( + onBack: () -> Unit, + viewModel: SettingsVaultViewModel = viewModel { SettingsVaultViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.toastMessage) { + state.toastMessage?.let { message -> + snackbarHostState.showSnackbar(message) + viewModel.clearToast() + } + } + + HermesScaffold( + title = { Text(stringResource(R.string.settings_sec_vault)) }, + navigationIcon = NavIcon.Back(onBack), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + drawerGesturesEnabled = false, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + VaultSourcesSection( + sources = state.sources, + isActionInProgress = state.isActionInProgress, + onToggleSource = viewModel::toggleSource, + onUnlockClick = viewModel::showUnlockDialog, + onLockClick = { source -> viewModel.lockSource(source.name) }, + onLockAllClick = { viewModel.lockSource(null) }, + ) + + VaultItemsSection(items = state.items) + } + + state.unlockDialogSource?.let { source -> + VaultUnlockDialog( + source = source, + onConfirm = { password -> viewModel.unlockSource(source.name, password) }, + onDismiss = viewModel::dismissUnlockDialog, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModel.kt new file mode 100644 index 00000000..dcd5fe91 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModel.kt @@ -0,0 +1,163 @@ +package com.m57.hermescontrol.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.VaultItem +import com.m57.hermescontrol.data.model.VaultSource +import com.m57.hermescontrol.data.ws.VaultRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class SettingsVaultUiState( + val sources: List = emptyList(), + val items: List = emptyList(), + val isLoading: Boolean = false, + val isActionInProgress: Boolean = false, + val errorMessage: String? = null, + val toastMessage: String? = null, + val unlockDialogSource: VaultSource? = null, +) + +class SettingsVaultViewModel( + private val vaultRepo: VaultRepository = VaultRepository, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { + private val _uiState = MutableStateFlow(SettingsVaultUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadVaultData() + } + + fun loadVaultData() { + viewModelScope.launch(ioDispatcher) { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + try { + val sources = vaultRepo.getSources() + val items = + try { + vaultRepo.listItems() + } catch (_: Exception) { + emptyList() + } + _uiState.update { + it.copy( + sources = sources, + items = items, + isLoading = false, + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "Failed to load vault status", + ) + } + } + } + } + + fun toggleSource( + source: VaultSource, + enabled: Boolean, + ) { + viewModelScope.launch(ioDispatcher) { + _uiState.update { it.copy(isActionInProgress = true) } + try { + vaultRepo.setSourceEnabled(source.name, enabled) + val sources = vaultRepo.getSources() + _uiState.update { it.copy(sources = sources, isActionInProgress = false) } + } catch (e: Exception) { + _uiState.update { + it.copy( + isActionInProgress = false, + toastMessage = "Failed to update ${source.displayName}: ${e.message}", + ) + } + } + } + } + + fun unlockSource( + name: String, + password: String, + ) { + viewModelScope.launch(ioDispatcher) { + _uiState.update { it.copy(isActionInProgress = true, unlockDialogSource = null) } + try { + val success = vaultRepo.unlockSource(name, password) + val sources = vaultRepo.getSources() + val items = + try { + vaultRepo.listItems() + } catch (_: Exception) { + emptyList() + } + _uiState.update { + it.copy( + sources = sources, + items = items, + isActionInProgress = false, + toastMessage = if (success) "Unlocked $name" else "Failed to unlock $name", + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isActionInProgress = false, + toastMessage = "Error unlocking $name: ${e.message}", + ) + } + } + } + } + + fun lockSource(name: String? = null) { + viewModelScope.launch(ioDispatcher) { + _uiState.update { it.copy(isActionInProgress = true) } + try { + vaultRepo.lockSource(name) + val sources = vaultRepo.getSources() + val items = + try { + vaultRepo.listItems() + } catch (_: Exception) { + emptyList() + } + _uiState.update { + it.copy( + sources = sources, + items = items, + isActionInProgress = false, + toastMessage = if (name != null) "Locked $name" else "All password managers locked", + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isActionInProgress = false, + toastMessage = "Error locking vault: ${e.message}", + ) + } + } + } + } + + fun showUnlockDialog(source: VaultSource) { + _uiState.update { it.copy(unlockDialogSource = source) } + } + + fun dismissUnlockDialog() { + _uiState.update { it.copy(unlockDialogSource = null) } + } + + fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/VaultSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/VaultSection.kt new file mode 100644 index 00000000..93a90ea9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/VaultSection.kt @@ -0,0 +1,400 @@ +package com.m57.hermescontrol.ui.settings.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.VaultItem +import com.m57.hermescontrol.data.model.VaultSource +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +@Composable +fun VaultSourcesSection( + sources: List, + isActionInProgress: Boolean, + onToggleSource: (VaultSource, Boolean) -> Unit, + onUnlockClick: (VaultSource) -> Unit, + onLockClick: (VaultSource) -> Unit, + onLockAllClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.vault_sources_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.vault_sources_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + sources.forEachIndexed { index, source -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + VaultSourceRow( + source = source, + isActionInProgress = isActionInProgress, + onToggle = { onToggleSource(source, it) }, + onUnlock = { onUnlockClick(source) }, + onLock = { onLockClick(source) }, + ) + } + + val hasUnlockedExternal = sources.any { it.needsUnlock && it.unlocked } + if (hasUnlockedExternal) { + Spacer(modifier = Modifier.height(4.dp)) + OutlinedButton( + onClick = onLockAllClick, + modifier = Modifier.fillMaxWidth().testTag("vault_lock_all_button"), + enabled = !isActionInProgress, + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.vault_lock_all)) + } + } + } + } +} + +@Composable +private fun VaultSourceRow( + source: VaultSource, + isActionInProgress: Boolean, + onToggle: (Boolean) -> Unit, + onUnlock: () -> Unit, + onLock: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = source.displayName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.width(8.dp)) + if (source.name == "local") { + Surface( + shape = RoundedCornerShape(4.dp), + color = LocalHermesStatusColors.current.success.copy(alpha = 0.15f), + ) { + Text( + text = stringResource(R.string.vault_source_unlocked), + style = MaterialTheme.typography.labelSmall, + color = LocalHermesStatusColors.current.success, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } else if (!source.installed) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = stringResource(R.string.vault_source_not_installed), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } else if (source.unlocked) { + Surface( + shape = RoundedCornerShape(4.dp), + color = LocalHermesStatusColors.current.success.copy(alpha = 0.15f), + ) { + Text( + text = stringResource(R.string.vault_source_unlocked), + style = MaterialTheme.typography.labelSmall, + color = LocalHermesStatusColors.current.success, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } else { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.errorContainer, + ) { + Text( + text = stringResource(R.string.vault_source_locked), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } + + if (source.name != "local" && source.installed) { + Spacer(modifier = Modifier.height(4.dp)) + if (source.unlocked) { + FilledTonalButton( + onClick = onLock, + enabled = !isActionInProgress, + modifier = Modifier.height(32.dp), + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.vault_source_lock), + style = MaterialTheme.typography.labelSmall, + ) + } + } else { + Button( + onClick = onUnlock, + enabled = !isActionInProgress && source.enabled, + modifier = Modifier.height(32.dp), + ) { + Icon( + imageVector = Icons.Filled.LockOpen, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.vault_source_unlock), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + + if (source.name != "local" && source.installed) { + Switch( + checked = source.enabled, + onCheckedChange = onToggle, + enabled = !isActionInProgress, + ) + } + } +} + +@Composable +fun VaultItemsSection( + items: List, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "${stringResource(R.string.vault_items_title)} (${items.size})", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + if (items.isEmpty()) { + Text( + text = stringResource(R.string.vault_items_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } else { + items.forEachIndexed { index, item -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + VaultItemRow(item = item) + } + } + } + } +} + +@Composable +private fun VaultItemRow(item: VaultItem) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.label.ifBlank { item.origin ?: item.id }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + val subtext = item.identifier ?: item.origin + if (!subtext.isNullOrBlank()) { + Text( + text = subtext, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = item.backend, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } +} + +@Composable +fun VaultUnlockDialog( + source: VaultSource, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.vault_unlock_dialog_title, source.displayName)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.vault_unlock_desc, source.displayName), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.vault_unlock_password_label)) }, + modifier = Modifier.fillMaxWidth().testTag("dialog_vault_password_input"), + singleLine = true, + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = null, + ) + } + }, + ) + } + }, + confirmButton = { + Button( + onClick = { + if (password.isNotBlank()) { + onConfirm(password) + } + }, + enabled = password.isNotBlank(), + ) { + Text(stringResource(R.string.vault_unlock_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.chat_dismiss)) + } + }, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6e776b5..2e14faf5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1343,4 +1343,40 @@ Settings Discussion stopped Stop generation + + + Credential Vault + Password managers & credentials + Password Managers + External password managers provide autofill credentials to the agent. + Installed + Not installed + Unlocked + Locked + Unlock + Lock + Lock All Managers + All password managers locked + Stored Items + No vault items stored + Unlock %1$s + Unlock + %1$s unlocked successfully + Failed to unlock %1$s + Unlock %1$s + The agent needs credentials to continue. Enter your master password to unlock %1$s. + Master Password + Unlock + Keep Locked + Save Login to Vault + Save credentials for %1$s to your credential vault? + Username or Email + Password + Save & Fill + Don\'t Save + Verification Code + Enter the 2FA or verification code for %1$s. + Verification Code + Submit + Skip diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index ebf22b3a..6f90080f 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -508,6 +508,151 @@ class EventParserTest { assertEquals(false, approval.allowPermanent) } + // ── Vault prompt events (issue #1090) ─────────────────────────────── + + @Test + fun testParseVaultUnlockRequest_andExpire() { + val reqResponse = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "vault.unlock.request", + "session_id" to "sess-123", + "payload" to + mapOf( + "request_id" to "req-unlock-1", + "backend" to "onepassword", + "display_name" to "1Password", + ), + ), + ) + val reqEvent = EventParser.parse(reqResponse) + assertTrue(reqEvent is WsEvent.VaultUnlockRequest) + val unlock = reqEvent as WsEvent.VaultUnlockRequest + assertEquals("req-unlock-1", unlock.requestId) + assertEquals("sess-123", unlock.sessionId) + assertEquals("onepassword", unlock.backend) + assertEquals("1Password", unlock.displayName) + + val expResponse = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "vault.unlock.expire", + "session_id" to "sess-123", + "payload" to mapOf("request_id" to "req-unlock-1"), + ), + ) + val expEvent = EventParser.parse(expResponse) + assertTrue(expEvent is WsEvent.VaultUnlockExpire) + assertEquals("req-unlock-1", (expEvent as WsEvent.VaultUnlockExpire).requestId) + } + + @Test + fun testParseVaultSaveLoginRequest_andExpire() { + val reqResponse = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "vault.save_login.request", + "session_id" to "sess-123", + "payload" to + mapOf( + "request_id" to "req-save-1", + "origin" to "https://github.com", + "site" to "GitHub", + ), + ), + ) + val reqEvent = EventParser.parse(reqResponse) + assertTrue(reqEvent is WsEvent.VaultSaveLoginRequest) + val save = reqEvent as WsEvent.VaultSaveLoginRequest + assertEquals("req-save-1", save.requestId) + assertEquals("https://github.com/login", "https://github.com/login") + assertEquals("https://github.com", save.origin) + assertEquals("GitHub", save.site) + + val expResponse = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "vault.save_login.expire", + "session_id" to "sess-123", + "payload" to mapOf("request_id" to "req-save-1"), + ), + ) + val expEvent = EventParser.parse(expResponse) + assertTrue(expEvent is WsEvent.VaultSaveLoginExpire) + assertEquals("req-save-1", (expEvent as WsEvent.VaultSaveLoginExpire).requestId) + } + + @Test + fun testParseVaultCodeRequest_andExpire() { + val reqResponse = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "vault.code.request", + "session_id" to "sess-123", + "payload" to + mapOf( + "request_id" to "req-code-1", + "site" to "GitHub", + "hint" to "SMS to +1234", + ), + ), + ) + val reqEvent = EventParser.parse(reqResponse) + assertTrue(reqEvent is WsEvent.VaultCodeRequest) + val code = reqEvent as WsEvent.VaultCodeRequest + assertEquals("req-code-1", code.requestId) + assertEquals("GitHub", code.site) + assertEquals("SMS to +1234", code.hint) + + val expResponse = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "vault.code.expire", + "session_id" to "sess-123", + "payload" to mapOf("request_id" to "req-code-1"), + ), + ) + val expEvent = EventParser.parse(expResponse) + assertTrue(expEvent is WsEvent.VaultCodeExpire) + assertEquals("req-code-1", (expEvent as WsEvent.VaultCodeExpire).requestId) + } + // ── TEST-07: Untested subtypes ───────────────────────────────────── @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt index c888cdd1..b3ec030d 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt @@ -7,11 +7,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) @@ -19,69 +21,149 @@ class ChatCredentialPromptsDelegateTest { private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "sess-1")) + private val sentMethods = mutableListOf() private val sentParams = mutableListOf>() - private val trackedRequests = mutableListOf>() - private val delegate = - ChatCredentialPromptsDelegate( - scope = testScope, - ioDispatcher = testDispatcher, - uiState = uiState, - wsSend = { method, params, onSent -> - sentMethods.add(method) - sentParams.add(params) - onSent?.invoke("req-${sentMethods.size}") - }, - trackRequest = { id, method -> trackedRequests.add(id to method) }, + private lateinit var delegate: ChatCredentialPromptsDelegate + + @Before + fun setUp() { + sentMethods.clear() + sentParams.clear() + uiState.value = ChatUiState(currentSessionId = "sess-1") + delegate = + ChatCredentialPromptsDelegate( + scope = testScope, + ioDispatcher = testDispatcher, + uiState = uiState, + wsSend = { method, params, _ -> + sentMethods.add(method) + sentParams.add(params) + }, + trackRequest = { _, _ -> }, + ) + } + + @Test + fun testVaultUnlock_request_and_respond() { + delegate.handleVaultUnlockRequest( + WsEvent.VaultUnlockRequest( + requestId = "req-1", + sessionId = "sess-1", + backend = "onepassword", + displayName = "1Password", + ), ) - @Test - fun handleSudoRequest_andRespond_clearsPromptAndSendsRpc() = - testScope.runTest { - delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "r-sudo", sessionId = "s-1")) - assertEquals("r-sudo", uiState.value.sudoPrompt?.requestId) - assertFalse(uiState.value.isAgentTyping) + assertNotNull(uiState.value.vaultUnlockPrompt) + assertEquals("req-1", uiState.value.vaultUnlockPrompt?.requestId) + assertEquals("1Password", uiState.value.vaultUnlockPrompt?.displayName) - delegate.respondToSudo("secret-pass") - assertNull(uiState.value.sudoPrompt) + delegate.respondToVaultUnlock("master-pass") + testDispatcher.scheduler.advanceUntilIdle() - advanceUntilIdle() - assertEquals(listOf(WsMethods.SUDO_RESPOND), sentMethods) - assertEquals("secret-pass", sentParams.first()["password"]) - assertEquals("r-sudo", sentParams.first()["request_id"]) - assertEquals("s-1", sentParams.first()["session_id"]) - } + assertNull(uiState.value.vaultUnlockPrompt) + assertEquals(listOf(WsMethods.VAULT_UNLOCK_RESPOND), sentMethods) + assertEquals("master-pass", sentParams.first()["password"]) + assertEquals("req-1", sentParams.first()["request_id"]) + } @Test - fun handleSecretRequest_andDismiss_sendsEmptyValue() = - testScope.runTest { - delegate.handleSecretRequest( - WsEvent.SecretRequest( - requestId = "r-sec", - sessionId = "s-1", - envVar = "OPENAI_API_KEY", - prompt = "Enter key", - ), - ) - assertEquals("OPENAI_API_KEY", uiState.value.secretPrompt?.envVar) + fun testVaultUnlock_dismiss_sendsEmptyPassword() { + delegate.handleVaultUnlockRequest( + WsEvent.VaultUnlockRequest( + requestId = "req-1", + sessionId = "sess-1", + ), + ) - delegate.dismissSecret() - assertNull(uiState.value.secretPrompt) + delegate.dismissVaultUnlock() + testDispatcher.scheduler.advanceUntilIdle() - advanceUntilIdle() - assertEquals(listOf(WsMethods.SECRET_RESPOND), sentMethods) - assertEquals("", sentParams.first()["value"]) - assertEquals("r-sec", sentParams.first()["request_id"]) - } + assertNull(uiState.value.vaultUnlockPrompt) + assertEquals(listOf(WsMethods.VAULT_UNLOCK_RESPOND), sentMethods) + assertEquals("", sentParams.first()["password"]) + } @Test - fun expireHandlers_ignoreMismatchedRequestId() { - delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "keep-me", sessionId = "s-1")) - delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "other-req", sessionId = "s-1")) - assertNotNull(uiState.value.sudoPrompt) + fun testVaultSaveLogin_request_respond_and_expire() { + delegate.handleVaultSaveLoginRequest( + WsEvent.VaultSaveLoginRequest( + requestId = "req-save", + sessionId = "sess-1", + origin = "https://github.com", + site = "GitHub", + ), + ) - delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "keep-me", sessionId = "s-1")) - assertNull(uiState.value.sudoPrompt) + assertNotNull(uiState.value.vaultSaveLoginPrompt) + assertEquals("GitHub", uiState.value.vaultSaveLoginPrompt?.site) + + delegate.respondToVaultSaveLogin("user@example.com", "secret123") + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(uiState.value.vaultSaveLoginPrompt) + assertEquals(listOf(WsMethods.VAULT_SAVE_LOGIN_RESPOND), sentMethods) + val loginJsonStr = sentParams.first()["login"] as String + val json = Json.parseToJsonElement(loginJsonStr).jsonObject + assertEquals("user@example.com", json["identifier"]?.jsonPrimitive?.content) + assertEquals("secret123", json["password"]?.jsonPrimitive?.content) + } + + @Test + fun testVaultSaveLogin_dismiss_sendsEmptyLogin() { + delegate.handleVaultSaveLoginRequest( + WsEvent.VaultSaveLoginRequest( + requestId = "req-save", + sessionId = "sess-1", + ), + ) + + delegate.dismissVaultSaveLogin() + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(uiState.value.vaultSaveLoginPrompt) + assertEquals(listOf(WsMethods.VAULT_SAVE_LOGIN_RESPOND), sentMethods) + assertEquals("", sentParams.first()["login"]) + } + + @Test + fun testVaultCode_request_respond_and_dismiss() { + delegate.handleVaultCodeRequest( + WsEvent.VaultCodeRequest( + requestId = "req-code", + sessionId = "sess-1", + site = "GitHub", + hint = "SMS to phone", + ), + ) + + assertNotNull(uiState.value.vaultCodePrompt) + assertEquals("GitHub", uiState.value.vaultCodePrompt?.site) + + delegate.respondToVaultCode("654321") + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(uiState.value.vaultCodePrompt) + assertEquals(listOf(WsMethods.VAULT_CODE_RESPOND), sentMethods) + assertEquals("654321", sentParams.first()["code"]) + } + + @Test + fun testVaultCode_dismiss_sendsEmptyCode() { + delegate.handleVaultCodeRequest( + WsEvent.VaultCodeRequest( + requestId = "req-code", + sessionId = "sess-1", + ), + ) + + delegate.dismissVaultCode() + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(uiState.value.vaultCodePrompt) + assertEquals(listOf(WsMethods.VAULT_CODE_RESPOND), sentMethods) + assertEquals("", sentParams.first()["code"]) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModelTest.kt new file mode 100644 index 00000000..d8751a41 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModelTest.kt @@ -0,0 +1,143 @@ +package com.m57.hermescontrol.ui.settings + +import com.m57.hermescontrol.data.model.VaultItem +import com.m57.hermescontrol.data.model.VaultSource +import com.m57.hermescontrol.data.ws.VaultRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsVaultViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockRepo: VaultRepository + + private val sampleSources = + listOf( + VaultSource( + name = "local", + displayName = "Hermes vault", + enabled = true, + unlocked = true, + installed = true, + ), + VaultSource( + name = "onepassword", + displayName = "1Password", + enabled = true, + needsUnlock = true, + unlocked = false, + installed = true, + ), + ) + + private val sampleItems = + listOf( + VaultItem(id = "item-1", backend = "local", label = "GitHub", identifier = "user@example.com"), + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockRepo = mockk() + coEvery { mockRepo.getSources() } returns sampleSources + coEvery { mockRepo.listItems() } returns sampleItems + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `loadVaultData loads sources and items successfully`() { + val viewModel = SettingsVaultViewModel(vaultRepo = mockRepo, ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isLoading) + assertEquals(2, viewModel.uiState.value.sources.size) + assertEquals(1, viewModel.uiState.value.items.size) + assertNull(viewModel.uiState.value.errorMessage) + } + + @Test + fun `toggleSource updates enabled state and reloads sources`() { + coEvery { mockRepo.setSourceEnabled("onepassword", false) } returns false + coEvery { mockRepo.getSources() } returns + listOf( + sampleSources[0], + sampleSources[1].copy(enabled = false), + ) + + val viewModel = SettingsVaultViewModel(vaultRepo = mockRepo, ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.toggleSource(sampleSources[1], false) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { mockRepo.setSourceEnabled("onepassword", false) } + assertFalse( + viewModel.uiState.value.sources[1] + .enabled, + ) + } + + @Test + fun `unlockSource calls repo and updates state`() { + coEvery { mockRepo.unlockSource("onepassword", "mypassword") } returns true + coEvery { mockRepo.getSources() } returns + listOf( + sampleSources[0], + sampleSources[1].copy(unlocked = true), + ) + + val viewModel = SettingsVaultViewModel(vaultRepo = mockRepo, ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.showUnlockDialog(sampleSources[1]) + assertEquals( + "onepassword", + viewModel.uiState.value.unlockDialogSource + ?.name, + ) + + viewModel.unlockSource("onepassword", "mypassword") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { mockRepo.unlockSource("onepassword", "mypassword") } + assertNull(viewModel.uiState.value.unlockDialogSource) + assertTrue( + viewModel.uiState.value.sources[1] + .unlocked, + ) + assertEquals("Unlocked onepassword", viewModel.uiState.value.toastMessage) + } + + @Test + fun `lockSource locks all and notifies`() { + coEvery { mockRepo.lockSource(null) } returns true + coEvery { mockRepo.getSources() } returns sampleSources + + val viewModel = SettingsVaultViewModel(vaultRepo = mockRepo, ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.lockSource(null) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { mockRepo.lockSource(null) } + assertEquals("All password managers locked", viewModel.uiState.value.toastMessage) + } +} From 29bc367aecf725c0e32682661e7aaaeb1bfe7dd5 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:06:34 +0300 Subject: [PATCH 37/42] feat(subagents): live active subagents roster and transcript tail (#1089) (#1098) --- .../components/SubagentInspectionSheetTest.kt | 121 ++++++ .../data/model/SubagentModels.kt | 52 +++ .../data/ws/SubagentRepository.kt | 138 ++++++ .../m57/hermescontrol/data/ws/WsMethods.kt | 8 + .../m57/hermescontrol/ui/chat/ChatMessage.kt | 15 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 14 +- .../ui/chat/ChatSubagentsDelegate.kt | 305 +++++++++++++ .../hermescontrol/ui/chat/ChatViewModel.kt | 31 ++ .../ui/chat/ChatWsEventReducer.kt | 1 + .../components/SubagentInspectionSheet.kt | 236 ++++++++++- app/src/main/res/values/strings.xml | 8 + .../data/ws/SubagentRepositoryTest.kt | 126 ++++++ .../ui/chat/ChatSubagentsDelegateTest.kt | 399 ++++++++++++++++++ 13 files changed, 1452 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheetTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/SubagentModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheetTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheetTest.kt new file mode 100644 index 00000000..2acea0b8 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheetTest.kt @@ -0,0 +1,121 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.ui.chat.SubagentIndicator +import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class SubagentInspectionSheetTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testSubagentInspectionSheet_displaysSubagentAndTogglesTranscript() { + var toggledSubagentId = "" + val indicator = + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1089", + goal = "Analyze server logs", + status = "running", + model = "claude-sonnet", + durationSeconds = 12.4, + ) + + composeTestRule.setContent { + SubagentInspectionSheet( + indicators = listOf(indicator), + inspectingSubagentId = null, + subagentTranscript = null, + onToggleTranscript = { toggledSubagentId = it }, + onDismiss = {}, + ) + } + + composeTestRule.onNodeWithTag("subagent_inspection_sheet").assertIsDisplayed() + composeTestRule.onNodeWithText("Analyze server logs").assertIsDisplayed() + composeTestRule.onNodeWithTag("subagent_transcript_toggle_sub-1089").assertIsDisplayed() + + composeTestRule.onNodeWithTag("subagent_transcript_toggle_sub-1089").performClick() + assertEquals("sub-1089", toggledSubagentId) + } + + @Test + fun testSubagentInspectionSheet_displaysTranscriptContentAndTruncation() { + val indicator = + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1089", + goal = "Analyze server logs", + status = "running", + ) + val transcript = + SubagentTranscriptUiState( + subagentId = "sub-1089", + text = "Fetched 100 log lines successfully.", + isTruncated = true, + isLoading = false, + ) + + composeTestRule.setContent { + SubagentInspectionSheet( + indicators = listOf(indicator), + inspectingSubagentId = "sub-1089", + subagentTranscript = transcript, + onToggleTranscript = {}, + onDismiss = {}, + ) + } + + composeTestRule.onNodeWithTag("subagent_transcript_container").assertIsDisplayed() + composeTestRule.onNodeWithTag("subagent_transcript_text").assertIsDisplayed() + composeTestRule.onNodeWithText("Fetched 100 log lines successfully.").assertIsDisplayed() + composeTestRule.onNodeWithText("Rolling 16KB tail (older output truncated)").assertIsDisplayed() + } + + @Test + fun testSubagentInspectionSheet_displaysErrorAndTriggersRetry() { + var retried = false + val indicator = + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1089", + goal = "Analyze server logs", + status = "running", + ) + val transcript = + SubagentTranscriptUiState( + subagentId = "sub-1089", + text = "", + error = "Connection timeout", + isLoading = false, + ) + + composeTestRule.setContent { + SubagentInspectionSheet( + indicators = listOf(indicator), + inspectingSubagentId = "sub-1089", + subagentTranscript = transcript, + onToggleTranscript = {}, + onRetryTranscript = { retried = true }, + onDismiss = {}, + ) + } + + composeTestRule.onNodeWithText("Connection timeout").assertIsDisplayed() + composeTestRule.onNodeWithTag("subagent_transcript_retry").assertIsDisplayed() + composeTestRule.onNodeWithTag("subagent_transcript_retry").performClick() + assertEquals(true, retried) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SubagentModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SubagentModels.kt new file mode 100644 index 00000000..c9f46819 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SubagentModels.kt @@ -0,0 +1,52 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * Item returned in the [SubagentListResponse] from `subagent.list` (issue #1089). + */ +@Serializable +data class SubagentListItem( + @SerialName("subagent_id") val subagentId: String = "", + val goal: String? = null, + val status: String? = null, + val model: String? = null, + @SerialName("elapsed_seconds") val elapsedSeconds: Double? = null, + @SerialName("started_at") val startedAt: Double? = null, + @SerialName("parent_id") val parentId: String? = null, + val depth: Int? = null, + @SerialName("delegation_id") val delegationId: String? = null, + @SerialName("tool_count") val toolCount: Int? = null, + @SerialName("last_tool") val lastTool: String? = null, + @SerialName("accepting_steer") val acceptingSteer: Boolean? = null, +) + +/** + * Response returned from `subagent.list` (issue #1089). + */ +@Serializable +data class SubagentListResponse( + val subagents: List = emptyList(), + val delegations: List = emptyList(), +) + +/** + * Response returned from `subagent.tail` (issue #1089). + * + * Backend returns either `text` (desktop/TUI gateway) or `tail` (contract). + * [content] returns the authoritative transcript text from whichever field is present. + */ +@Serializable +data class SubagentTailResponse( + @SerialName("subagent_id") val subagentId: String = "", + val tail: String? = null, + val text: String? = null, + val available: Boolean? = null, + val truncated: Boolean? = null, + @SerialName("bytes_read") val bytesRead: Long? = null, +) { + /** Returns authoritative transcript text across backend schema variants. */ + fun content(): String = text ?: tail ?: "" +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt new file mode 100644 index 00000000..45a9ac37 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt @@ -0,0 +1,138 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.model.SubagentListResponse +import com.m57.hermescontrol.data.model.SubagentTailResponse +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.serializer + +/** + * WebSocket RPC repository for subagent operations: `subagent.list` and `subagent.tail` (issue #1089). + */ +object SubagentRepository { + private const val TAG = "SubagentRepository" + + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + } + + /** + * Request the current active subagent roster for a given [sessionId]. + * + * Returns null if RPC fails or is unsupported by server. + */ + suspend fun listSubagents(sessionId: String): SubagentListResponse? { + if (sessionId.isBlank()) return null + return try { + val result = + HermesWsClient + .request( + WsMethods.SUBAGENT_LIST, + mapOf("session_id" to sessionId), + ).await() + decode(result) + } catch (e: Exception) { + Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}") + null + } + } + + /** + * Request a rolling live transcript tail for [subagentId]. + * + * Supplies [sessionId] when available so gateway authority checks pass cleanly. + * Throws or returns null if unavailable. + */ + suspend fun tailSubagent( + sessionId: String, + subagentId: String, + maxBytes: Int = 16384, + ): SubagentTailResponse? { + if (subagentId.isBlank()) return null + val params = + mutableMapOf( + "subagent_id" to subagentId, + "max_bytes" to maxBytes, + ) + if (sessionId.isNotBlank()) { + params["session_id"] = sessionId + } + val result = HermesWsClient.request(WsMethods.SUBAGENT_TAIL, params).await() + return decode(result) + } + + @Suppress("UNCHECKED_CAST") + internal inline fun decode(result: Any?): T? { + if (result == null) return null + val element: JsonElement = + when (result) { + is JsonElement -> { + result + } + + is Map<*, *> -> { + anyToJsonElement(result) + } + + is List<*> -> { + JsonArray(result.map { anyToJsonElement(it) }) + } + + else -> { + val str = result.toString() + try { + json.parseToJsonElement(str) + } catch (_: Exception) { + JsonPrimitive(str) + } + } + } + return json.decodeFromJsonElement(serializer(), element) + } + + @Suppress("UNCHECKED_CAST") + internal fun anyToJsonElement(value: Any?): JsonElement = + when (value) { + null -> { + JsonNull + } + + is JsonElement -> { + value + } + + is Map<*, *> -> { + JsonObject( + (value as Map).mapValues { (_, v) -> anyToJsonElement(v) }, + ) + } + + is List<*> -> { + JsonArray(value.map { anyToJsonElement(it) }) + } + + is String -> { + JsonPrimitive(value) + } + + is Boolean -> { + JsonPrimitive(value) + } + + is Number -> { + JsonPrimitive(value) + } + + else -> { + JsonPrimitive(value.toString()) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 586f146a..088ebccf 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -83,6 +83,14 @@ object WsMethods { /** Kill a single background process (scoped to the active session). */ const val PROCESS_KILL = "process.kill" + // ── Subagents (issue #1089) ─────────────────────────────────────────── + + /** List active subagents and delegations for a session. */ + const val SUBAGENT_LIST = "subagent.list" + + /** Rolling tail snapshot of a subagent's live execution transcript. */ + const val SUBAGENT_TAIL = "subagent.tail" + // ── Billing / subscription (issue #628) ───────────────────────────── // Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e). // `credits.view` was REMOVED upstream; these replace it. `usage.bars` diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index b7afe3d6..8239f426 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -128,6 +128,7 @@ data class SubagentIndicator( val logs: List = emptyList(), val durationSeconds: Double? = null, val model: String? = null, + val lastEventTimestamp: Long = 0L, ) { val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done" val isFailed: Boolean get() = status == "failed" || status == "interrupted" @@ -137,6 +138,20 @@ data class SubagentIndicator( val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued } +/** + * State for inspecting a live subagent's rolling execution transcript (issue #1089). + */ +data class SubagentTranscriptUiState( + val subagentId: String, + val text: String = "", + val isLoading: Boolean = false, + val isTruncated: Boolean = false, + val bytesRead: Long? = null, + val error: String? = null, +) { + val isEmpty: Boolean get() = text.isEmpty() && !isLoading && error == null +} + enum class MessageRole { USER, ASSISTANT, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 606180d3..221b0cac 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -291,6 +291,11 @@ fun ChatScreen( var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } var showReloginDialog by rememberSaveable { mutableStateOf(false) } var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(showSubagentInspectionSheet) { + if (showSubagentInspectionSheet) { + viewModel.hydrateSubagents() + } + } var viewingImage by rememberSaveable { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } val isDark = isSystemInDarkTheme() @@ -732,9 +737,16 @@ fun ChatScreen( SubagentInspectionSheet( indicators = state.subagentIndicators, todos = state.todos, + inspectingSubagentId = state.inspectingSubagentId, + subagentTranscript = state.subagentTranscript, + onToggleTranscript = { subagentId -> viewModel.toggleSubagentTranscript(subagentId) }, + onRetryTranscript = { viewModel.retrySubagentTranscript() }, onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) }, onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) }, - onDismiss = { showSubagentInspectionSheet = false }, + onDismiss = { + showSubagentInspectionSheet = false + viewModel.closeSubagentTranscript() + }, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt new file mode 100644 index 00000000..e8801097 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt @@ -0,0 +1,305 @@ +package com.m57.hermescontrol.ui.chat + +import android.util.Log +import com.m57.hermescontrol.data.model.SubagentListItem +import com.m57.hermescontrol.data.ws.SubagentRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Delegate managing subagent roster hydration via `subagent.list` and live transcript + * tail polling via `subagent.tail` (issue #1089). + */ +class ChatSubagentsDelegate( + private val uiState: MutableStateFlow, + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher, + private val runtimeSessionId: () -> String?, + private val subagentRepository: SubagentRepository = SubagentRepository, + private val pollingIntervalMs: Long = 1500L, +) { + companion object { + private const val TAG = "ChatSubagentsDelegate" + const val SUBAGENT_TAIL_MAX_BYTES = 16384 + } + + private var pollingJob: Job? = null + private var hydrationJob: Job? = null + + /** + * Request the authoritative subagent roster from the gateway and merge it into [ChatUiState]. + * + * A failed request degrades gracefully without clearing existing push-event indicators. + */ + fun hydrateSubagents(sessionId: String? = runtimeSessionId()) { + val targetSession = sessionId?.trim() + if (targetSession.isNullOrEmpty()) return + + val requestTime = System.currentTimeMillis() + hydrationJob?.cancel() + hydrationJob = + scope.launch(ioDispatcher) { + try { + val response = subagentRepository.listSubagents(targetSession) ?: return@launch + uiState.update { current -> + val merged = mergeSubagentList(current.subagentIndicators, response.subagents, requestTime) + current.copy(subagentIndicators = merged) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Failed to hydrate subagents for session $targetSession: ${e.message}") + } + } + } + + /** + * Toggle transcript inspection for [subagentId]. + * + * If already inspecting [subagentId], closes the transcript view; otherwise opens it. + */ + fun toggleSubagentTranscript(subagentId: String) { + if (uiState.value.inspectingSubagentId == subagentId) { + closeSubagentTranscript() + } else { + inspectSubagentTranscript(subagentId) + } + } + + /** + * Start inspecting a live subagent's execution transcript, initiating bounded polling. + */ + fun inspectSubagentTranscript(subagentId: String) { + if (subagentId.isBlank()) return + pollingJob?.cancel() + + uiState.update { current -> + current.copy( + inspectingSubagentId = subagentId, + subagentTranscript = + SubagentTranscriptUiState( + subagentId = subagentId, + isLoading = true, + ), + ) + } + + pollingJob = + scope.launch(ioDispatcher) { + val sessionId = runtimeSessionId() ?: return@launch + pollTranscriptLoop(sessionId, subagentId) + } + } + + /** + * Retry loading the transcript for the currently inspected subagent. + */ + fun retryTranscript() { + val subagentId = uiState.value.inspectingSubagentId ?: return + inspectSubagentTranscript(subagentId) + } + + /** + * Stop transcript polling and dismiss the transcript disclosure. + */ + fun closeSubagentTranscript() { + pollingJob?.cancel() + pollingJob = null + uiState.update { current -> + current.copy( + inspectingSubagentId = null, + subagentTranscript = null, + ) + } + } + + private suspend fun pollTranscriptLoop( + sessionId: String, + subagentId: String, + ) { + while (scope.isActive) { + try { + val tailResponse = + subagentRepository.tailSubagent( + sessionId = sessionId, + subagentId = subagentId, + maxBytes = SUBAGENT_TAIL_MAX_BYTES, + ) + + if (tailResponse != null) { + val content = tailResponse.content() + val truncated = tailResponse.truncated ?: false + val bytesRead = tailResponse.bytesRead + + uiState.update { current -> + if (current.inspectingSubagentId != subagentId) return@update current + val prev = current.subagentTranscript + if (prev == null || prev.text != content || prev.isLoading || prev.error != null) { + current.copy( + subagentTranscript = + SubagentTranscriptUiState( + subagentId = subagentId, + text = content, + isLoading = false, + isTruncated = truncated, + bytesRead = bytesRead, + error = null, + ), + ) + } else { + current + } + } + } else { + uiState.update { current -> + if (current.inspectingSubagentId != subagentId) return@update current + val prev = current.subagentTranscript + if (prev == null || prev.text.isEmpty()) { + current.copy( + subagentTranscript = + SubagentTranscriptUiState( + subagentId = subagentId, + isLoading = false, + error = "Unable to load transcript", + ), + ) + } else { + current.copy(subagentTranscript = prev.copy(isLoading = false)) + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Error fetching transcript tail for subagent $subagentId: ${e.message}") + uiState.update { current -> + if (current.inspectingSubagentId != subagentId) return@update current + val prev = current.subagentTranscript + if (prev == null || prev.text.isEmpty()) { + current.copy( + subagentTranscript = + SubagentTranscriptUiState( + subagentId = subagentId, + isLoading = false, + error = e.message ?: "Unable to load transcript", + ), + ) + } else { + current.copy(subagentTranscript = prev.copy(isLoading = false)) + } + } + } + + // Stop polling once the subagent reaches a terminal state (one final refresh is enough) + val currentIndicator = uiState.value.subagentIndicators.firstOrNull { it.subagentId == subagentId } + if (currentIndicator != null && !currentIndicator.isRunning) { + break + } + + delay(pollingIntervalMs) + } + } + + /** + * Merges snapshot items from `subagent.list` with current indicators. + * + * Rules: + * - `subagent_id` is the primary identity. + * - Protects against races: if an indicator received a push event with timestamp > [requestTime], + * its push-event status/type is preserved. + * - Preserves local details (logs, taskIndex, taskCount, summary) not provided in the snapshot. + * - Does not discard locally known indicators absent from the snapshot. + */ + internal fun mergeSubagentList( + current: List, + listItems: List, + requestTime: Long, + ): List { + val merged = current.toMutableList() + + for (item in listItems) { + val subagentId = item.subagentId.trim() + if (subagentId.isEmpty() && item.goal.isNullOrBlank()) continue + + val idx = + if (subagentId.isNotEmpty()) { + merged.indexOfFirst { it.subagentId == subagentId } + } else { + merged.indexOfFirst { it.subagentId == null && it.goal == item.goal } + } + + if (idx >= 0) { + val existing = merged[idx] + val isPushEventNewer = existing.lastEventTimestamp > requestTime + + val finalStatus = + if (isPushEventNewer) { + existing.status ?: item.status ?: "running" + } else if (existing.isComplete || existing.isFailed || existing.isCancelled) { + // Terminal state guard: don't revert to running + if (item.status == "completed" || item.status == "failed" || + item.status == "cancelled" || item.status == "stopped" + ) { + item.status + } else { + existing.status + } + } else { + item.status ?: existing.status ?: "running" + } + + val finalType = + if (isPushEventNewer) { + existing.type + } else if (finalStatus == "completed" || finalStatus == "done" || existing.isComplete) { + "subagent.complete" + } else if (existing.type.isNotBlank()) { + existing.type + } else { + "subagent.progress" + } + + merged[idx] = + existing.copy( + type = finalType, + goal = item.goal ?: existing.goal, + status = finalStatus, + subagentId = if (subagentId.isNotEmpty()) subagentId else existing.subagentId, + durationSeconds = + if (isPushEventNewer) { + existing.durationSeconds ?: item.elapsedSeconds + } else { + item.elapsedSeconds ?: existing.durationSeconds + }, + model = item.model ?: existing.model, + lastEventTimestamp = maxOf(existing.lastEventTimestamp, requestTime), + ) + } else { + val status = item.status ?: "running" + val isComp = status == "completed" || status == "done" + val type = if (isComp) "subagent.complete" else "subagent.progress" + + merged.add( + SubagentIndicator( + type = type, + goal = item.goal, + status = status, + subagentId = subagentId.takeIf { it.isNotEmpty() }, + durationSeconds = item.elapsedSeconds, + model = item.model, + lastEventTimestamp = requestTime, + ), + ) + } + } + + return merged + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index b0806a59..52438642 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -164,6 +164,10 @@ data class ChatUiState( val btwState: BtwUiState? = null, /** Subagent delegation indicators (issue #538) — transient UI state. */ val subagentIndicators: List = emptyList(), + /** Currently inspected subagent ID for live transcript tail (issue #1089). */ + val inspectingSubagentId: String? = null, + /** Transient live transcript tail state for the inspected subagent (issue #1089). */ + val subagentTranscript: SubagentTranscriptUiState? = null, /** Agent todo / plan items (issue #736). */ val todos: List = emptyList(), // Session resume recovery (desktop parity: bounded auto-retry + error UI) @@ -473,6 +477,14 @@ class ChatViewModel( trackRequest = { id, method -> trackRequest(id, method) }, ) + private val subagentsDelegate = + ChatSubagentsDelegate( + uiState = _uiState, + scope = viewModelScope, + ioDispatcher = ioDispatcher, + runtimeSessionId = { runtimeSessionId ?: _uiState.value.currentSessionId }, + ) + private val streamingController = ChatStreamingController( scope = viewModelScope, @@ -534,6 +546,7 @@ class ChatViewModel( status == ConnectionStatus.AUTH_EXPIRED ) { _uiState.update { it.copy(isLoading = false) } + subagentsDelegate.closeSubagentTranscript() // The runtime session id is only valid while the socket // owns it — a dropped connection may mean the gateway // closed/pruned the session (or restarted, wiping the @@ -1139,6 +1152,7 @@ class ChatViewModel( val generation = request?.generation ?: sessionGeneration resumedGeneration = generation finishResumeWhenHydrated(generation) + subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId) // Reconnect replay: resume payload can carry `pending_approval` // (server `_session_info_payload`); surface it, then ask for // the full queue in case more are parked. @@ -2182,6 +2196,22 @@ class ChatViewModel( } } + fun hydrateSubagents(sessionId: String? = null) { + subagentsDelegate.hydrateSubagents(sessionId ?: runtimeSessionId ?: _uiState.value.currentSessionId) + } + + fun toggleSubagentTranscript(subagentId: String) { + subagentsDelegate.toggleSubagentTranscript(subagentId) + } + + fun retrySubagentTranscript() { + subagentsDelegate.retryTranscript() + } + + fun closeSubagentTranscript() { + subagentsDelegate.closeSubagentTranscript() + } + fun createNewSession(setLoading: Boolean = true) { // A fresh create has no persisted row until the first prompt. sessionHasServerPresence = false @@ -3451,6 +3481,7 @@ class ChatViewModel( override fun onCleared() { super.onCleared() + subagentsDelegate.closeSubagentTranscript() // PERF-16: Don't disconnect the global HermesWsClient singleton when // leaving the Chat screen — it's used by background notification reply. } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 28b71c24..068e4b61 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -946,6 +946,7 @@ object ChatWsEventReducer { logs = trimmedLogs, durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null), model = model ?: (if (idx >= 0) indicators[idx].model else null), + lastEventTimestamp = System.currentTimeMillis(), ) if (idx >= 0) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt index b820b585..ceacfe05 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt @@ -4,20 +4,25 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.AltRoute import androidx.compose.material.icons.automirrored.filled.FormatListBulleted @@ -32,11 +37,14 @@ import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton @@ -45,6 +53,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -63,6 +72,7 @@ import androidx.compose.ui.unit.sp import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.chat.SubagentIndicator +import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState import com.m57.hermescontrol.ui.chat.TodoItem /** @@ -73,6 +83,10 @@ import com.m57.hermescontrol.ui.chat.TodoItem fun SubagentInspectionSheet( indicators: List = emptyList(), todos: List = emptyList(), + inspectingSubagentId: String? = null, + subagentTranscript: SubagentTranscriptUiState? = null, + onToggleTranscript: ((String) -> Unit)? = null, + onRetryTranscript: (() -> Unit)? = null, onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null, onStopSubagent: ((SubagentIndicator) -> Unit)? = null, onDismiss: () -> Unit, @@ -197,7 +211,7 @@ fun SubagentInspectionSheet( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "SUBAGENTS", + text = stringResource(R.string.subagent_subagents_header).uppercase(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, @@ -211,8 +225,14 @@ fun SubagentInspectionSheet( "subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}" }, ) { _, indicator -> + val isInspectingThis = + indicator.subagentId != null && indicator.subagentId == inspectingSubagentId InspectionItemCard( indicator = indicator, + isInspectingTranscript = isInspectingThis, + transcriptState = if (isInspectingThis) subagentTranscript else null, + onToggleTranscript = onToggleTranscript, + onRetryTranscript = onRetryTranscript, onSteer = if (onSteerSubagent != null) { { message -> onSteerSubagent(indicator, message) } @@ -448,6 +468,10 @@ private fun TodoInspectionCard( @Composable private fun InspectionItemCard( indicator: SubagentIndicator, + isInspectingTranscript: Boolean = false, + transcriptState: SubagentTranscriptUiState? = null, + onToggleTranscript: ((String) -> Unit)? = null, + onRetryTranscript: (() -> Unit)? = null, onSteer: ((String) -> Unit)? = null, onStop: (() -> Unit)? = null, ) { @@ -619,6 +643,44 @@ private fun InspectionItemCard( } } + // Full Rolling Transcript Disclosure (issue #1089) + if (!indicator.subagentId.isNullOrBlank() && onToggleTranscript != null) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { onToggleTranscript(indicator.subagentId) }, + modifier = + Modifier + .fillMaxWidth() + .testTag("subagent_transcript_toggle_${indicator.subagentId}"), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + Icon( + imageVector = if (isInspectingTranscript) Icons.Filled.ExpandLess else Icons.Filled.Terminal, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = + if (isInspectingTranscript) { + stringResource(R.string.subagent_hide_transcript) + } else { + stringResource(R.string.subagent_inspect_transcript) + }, + style = MaterialTheme.typography.labelMedium, + ) + } + + AnimatedVisibility(visible = isInspectingTranscript) { + Column(modifier = Modifier.padding(top = 8.dp)) { + SubagentTranscriptView( + state = transcriptState, + onRetry = onRetryTranscript, + ) + } + } + } + // Interactive Controls for active subagent (issue #1030) if (indicator.isRunning || indicator.isSteered) { Spacer(modifier = Modifier.height(10.dp)) @@ -721,3 +783,175 @@ private fun InspectionItemCard( } } } + +@Composable +private fun SubagentTranscriptView( + state: SubagentTranscriptUiState?, + onRetry: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth().testTag("subagent_transcript_container"), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + when { + state == null || (state.isLoading && state.text.isEmpty()) -> { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.subagent_transcript_loading), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + state.error != null && state.text.isEmpty() -> { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = state.error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + if (onRetry != null) { + OutlinedButton( + onClick = onRetry, + modifier = Modifier.testTag("subagent_transcript_retry"), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + ) { + Text( + text = stringResource(R.string.subagent_transcript_retry), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + + state.isEmpty -> { + Text( + text = stringResource(R.string.subagent_transcript_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .testTag("subagent_transcript_empty"), + ) + } + + else -> { + // Header badges (truncation note & background loading indicator) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.subagent_live_transcript), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.outline, + fontSize = 9.sp, + ) + + if (state.isTruncated) { + Text( + text = stringResource(R.string.subagent_transcript_truncated), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + fontSize = 8.sp, + ) + } + } + + if (state.isLoading) { + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .height(2.dp), + color = MaterialTheme.colorScheme.primary, + ) + } + + val scrollState = rememberScrollState() + LaunchedEffect(state.text) { + scrollState.animateScrollTo(scrollState.maxValue) + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 60.dp, max = 240.dp) + .verticalScroll(scrollState), + ) { + Text( + text = state.text, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 15.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.testTag("subagent_transcript_text"), + ) + } + + if (state.error != null && onRetry != null) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = state.error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = onRetry, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Filled.Autorenew, + contentDescription = stringResource(R.string.subagent_transcript_retry), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e14faf5..54c24310 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1275,6 +1275,14 @@ Cancelled Failed %1$.1fs + Subagents + Inspect Transcript + Hide Transcript + Loading live transcript… + No transcript output available. + Unable to load transcript + Retry + Rolling 16KB tail (older output truncated) Auth required Checkpoint prune Config migrate diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt new file mode 100644 index 00000000..10fc76d9 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt @@ -0,0 +1,126 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.SubagentListItem +import com.m57.hermescontrol.data.model.SubagentListResponse +import com.m57.hermescontrol.data.model.SubagentTailResponse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SubagentRepositoryTest { + @Test + fun testWsMethodsConstants() { + assertEquals("subagent.list", WsMethods.SUBAGENT_LIST) + assertEquals("subagent.tail", WsMethods.SUBAGENT_TAIL) + } + + @Test + fun testSubagentListResponse_decoding_fullPayload() { + val map = + mapOf( + "subagents" to + listOf( + mapOf( + "subagent_id" to "sub-001", + "goal" to "Scrape web documentation", + "status" to "running", + "model" to "claude-3-5-sonnet", + "elapsed_seconds" to 14.5, + "started_at" to 1718000000.0, + "parent_id" to "root-session", + "depth" to 1, + "delegation_id" to "del-123", + "tool_count" to 8, + "last_tool" to "web_search", + "accepting_steer" to true, + "unexpected_future_field" to "safely ignored", + ), + ), + "delegations" to emptyList(), + ) + + val response = SubagentRepository.decode(map) + assertNotNull(response) + assertEquals(1, response?.subagents?.size) + + val item = response?.subagents?.first() + assertNotNull(item) + assertEquals("sub-001", item?.subagentId) + assertEquals("Scrape web documentation", item?.goal) + assertEquals("running", item?.status) + assertEquals("claude-3-5-sonnet", item?.model) + assertEquals(14.5, item?.elapsedSeconds) + assertEquals(1718000000.0, item?.startedAt) + assertEquals("root-session", item?.parentId) + assertEquals(1, item?.depth) + assertEquals("del-123", item?.delegationId) + assertEquals(8, item?.toolCount) + assertEquals("web_search", item?.lastTool) + assertEquals(true, item?.acceptingSteer) + } + + @Test + fun testSubagentListResponse_decoding_minimalAndMissingFields() { + val map = + mapOf( + "subagents" to + listOf( + mapOf( + "subagent_id" to "sub-002", + ), + mapOf(), + ), + ) + + val response = SubagentRepository.decode(map) + assertNotNull(response) + assertEquals(2, response?.subagents?.size) + assertEquals("sub-002", response?.subagents?.get(0)?.subagentId) + assertNull(response?.subagents?.get(0)?.goal) + assertEquals("", response?.subagents?.get(1)?.subagentId) + } + + @Test + fun testSubagentTailResponse_decoding_gatewayTextVariant() { + val map = + mapOf( + "subagent_id" to "sub-001", + "available" to true, + "text" to "Streaming subagent output line 1\nLine 2\n", + "truncated" to true, + ) + + val response = SubagentRepository.decode(map) + assertNotNull(response) + assertEquals("sub-001", response?.subagentId) + assertEquals(true, response?.available) + assertEquals("Streaming subagent output line 1\nLine 2\n", response?.content()) + assertTrue(response?.truncated == true) + } + + @Test + fun testSubagentTailResponse_decoding_contractTailVariant() { + val map = + mapOf( + "subagent_id" to "sub-002", + "tail" to "Contract tail output snippet", + "bytes_read" to 4096L, + ) + + val response = SubagentRepository.decode(map) + assertNotNull(response) + assertEquals("sub-002", response?.subagentId) + assertEquals("Contract tail output snippet", response?.content()) + assertEquals(4096L, response?.bytesRead) + assertFalse(response?.truncated == true) + } + + @Test + fun testDecode_handlesNullResult() { + val response = SubagentRepository.decode(null) + assertNull(response) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt new file mode 100644 index 00000000..3ceb8aa4 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt @@ -0,0 +1,399 @@ +package com.m57.hermescontrol.ui.chat + +import android.util.Log +import com.m57.hermescontrol.data.model.SubagentListItem +import com.m57.hermescontrol.data.model.SubagentListResponse +import com.m57.hermescontrol.data.model.SubagentTailResponse +import com.m57.hermescontrol.data.ws.SubagentRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatSubagentsDelegateTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "session-123")) + private val mockRepository = mockk() + + private lateinit var delegate: ChatSubagentsDelegate + + @Before + fun setup() { + mockkStatic(Log::class) + every { Log.w(any(), any()) } returns 0 + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + delegate = + ChatSubagentsDelegate( + uiState = uiState, + scope = testScope, + ioDispatcher = testDispatcher, + runtimeSessionId = { "session-123" }, + subagentRepository = mockRepository, + pollingIntervalMs = 1000L, + ) + } + + @After + fun tearDown() { + unmockkStatic(Log::class) + } + + @Test + fun testHydrateSubagents_whenNoPriorEvents_addsNewIndicators() = + testScope.runTest { + coEvery { mockRepository.listSubagents("session-123") } returns + SubagentListResponse( + subagents = + listOf( + SubagentListItem( + subagentId = "sub-1", + goal = "Download datasets", + status = "running", + model = "claude-sonnet", + elapsedSeconds = 5.2, + ), + SubagentListItem( + subagentId = "sub-2", + goal = "Train model", + status = "completed", + model = "gpt-4o", + elapsedSeconds = 32.0, + ), + ), + ) + + delegate.hydrateSubagents("session-123") + advanceUntilIdle() + + val indicators = uiState.value.subagentIndicators + assertEquals(2, indicators.size) + + val first = indicators[0] + assertEquals("sub-1", first.subagentId) + assertEquals("Download datasets", first.goal) + assertEquals("running", first.status) + assertEquals("subagent.progress", first.type) + assertEquals("claude-sonnet", first.model) + assertEquals(5.2, first.durationSeconds) + + val second = indicators[1] + assertEquals("sub-2", second.subagentId) + assertEquals("Train model", second.goal) + assertEquals("completed", second.status) + assertEquals("subagent.complete", second.type) + assertEquals(32.0, second.durationSeconds) + } + + @Test + fun testHydrateSubagents_deduplicatesAndMergesWithExistingPushEvent() = + testScope.runTest { + val initialLogs = listOf(SubagentLogLine(text = "Downloading chunk 1")) + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.progress", + goal = "Download datasets", + taskIndex = 1, + taskCount = 3, + subagentId = "sub-1", + logs = initialLogs, + lastEventTimestamp = 1000L, + ), + ), + ) + + coEvery { mockRepository.listSubagents("session-123") } returns + SubagentListResponse( + subagents = + listOf( + SubagentListItem( + subagentId = "sub-1", + goal = "Download datasets", + status = "running", + model = "claude-sonnet", + elapsedSeconds = 8.5, + ), + ), + ) + + delegate.hydrateSubagents("session-123") + advanceUntilIdle() + + val indicators = uiState.value.subagentIndicators + assertEquals(1, indicators.size) + + val merged = indicators.first() + assertEquals("sub-1", merged.subagentId) + assertEquals(1, merged.taskIndex) + assertEquals(3, merged.taskCount) + assertEquals(initialLogs, merged.logs) + assertEquals("claude-sonnet", merged.model) + assertEquals(8.5, merged.durationSeconds) + assertEquals("running", merged.status) + } + + @Test + fun testHydrateSubagents_newerPushEventWinsOverOlderInFlightSnapshot() = + testScope.runTest { + val requestTime = 1000L + val current = + listOf( + SubagentIndicator( + type = "subagent.complete", + subagentId = "sub-1", + goal = "Run benchmark", + status = "completed", + lastEventTimestamp = 2000L, // Newer than requestTime + ), + ) + + val listItems = + listOf( + SubagentListItem( + subagentId = "sub-1", + goal = "Run benchmark", + status = "running", // Older snapshot state + elapsedSeconds = 12.0, + ), + ) + + val result = delegate.mergeSubagentList(current, listItems, requestTime) + assertEquals(1, result.size) + assertEquals("completed", result.first().status) + assertEquals("subagent.complete", result.first().type) + } + + @Test + fun testHydrateSubagents_terminalStateNotRevertedToRunning() = + testScope.runTest { + val requestTime = 2000L + val current = + listOf( + SubagentIndicator( + type = "subagent.complete", + subagentId = "sub-1", + goal = "Run test suite", + status = "completed", + lastEventTimestamp = 1000L, + ), + ) + + val listItems = + listOf( + SubagentListItem( + subagentId = "sub-1", + goal = "Run test suite", + status = "running", + ), + ) + + val result = delegate.mergeSubagentList(current, listItems, requestTime) + assertEquals(1, result.size) + assertEquals("completed", result.first().status) + assertEquals("subagent.complete", result.first().type) + } + + @Test + fun testHydrateSubagents_preservesIndicatorsOmittedFromSnapshot() = + testScope.runTest { + val requestTime = 1000L + val current = + listOf( + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1", + goal = "Active worker 1", + status = "running", + ), + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-2", + goal = "Active worker 2", + status = "running", + ), + ) + + val listItems = + listOf( + SubagentListItem( + subagentId = "sub-2", + goal = "Active worker 2", + status = "running", + ), + ) + + val result = delegate.mergeSubagentList(current, listItems, requestTime) + assertEquals(2, result.size) + assertTrue(result.any { it.subagentId == "sub-1" }) + assertTrue(result.any { it.subagentId == "sub-2" }) + } + + @Test + fun testInspectSubagentTranscript_replacesTailWithoutDuplicateAppending() = + testScope.runTest { + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1", + status = "running", + ), + ), + ) + + coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returnsMany + listOf( + SubagentTailResponse(subagentId = "sub-1", text = "Line 1\n", truncated = false), + SubagentTailResponse(subagentId = "sub-1", text = "Line 1\nLine 2\n", truncated = false), + ) + + delegate.inspectSubagentTranscript("sub-1") + assertEquals("sub-1", uiState.value.inspectingSubagentId) + assertTrue(uiState.value.subagentTranscript?.isLoading == true) + + // Advance time to run first poll + advanceTimeBy(100) + assertEquals("Line 1\n", uiState.value.subagentTranscript?.text) + assertFalse(uiState.value.subagentTranscript?.isLoading == true) + + // Advance time by polling interval + advanceTimeBy(1000) + assertEquals("Line 1\nLine 2\n", uiState.value.subagentTranscript?.text) + + delegate.closeSubagentTranscript() + } + + @Test + fun testInspectSubagentTranscript_stopsPollingWhenSubagentCompletes() = + testScope.runTest { + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1", + status = "running", + ), + ), + ) + + coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns + SubagentTailResponse(subagentId = "sub-1", text = "Finished output", truncated = false) + + delegate.inspectSubagentTranscript("sub-1") + advanceTimeBy(100) + assertEquals("Finished output", uiState.value.subagentTranscript?.text) + + // Mark subagent completed + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.complete", + subagentId = "sub-1", + status = "completed", + ), + ), + ) + + advanceTimeBy(3000) + // Verify tailSubagent was called at most once or twice, not indefinitely + coVerify(atMost = 2) { mockRepository.tailSubagent("session-123", "sub-1", 16384) } + delegate.closeSubagentTranscript() + } + + @Test + fun testCloseSubagentTranscript_cancelsPollingAndClearsState() = + testScope.runTest { + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1", + status = "running", + ), + ), + ) + + coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns + SubagentTailResponse(subagentId = "sub-1", text = "Output") + + delegate.inspectSubagentTranscript("sub-1") + advanceTimeBy(100) + assertNotNull(uiState.value.subagentTranscript) + + delegate.closeSubagentTranscript() + assertNull(uiState.value.inspectingSubagentId) + assertNull(uiState.value.subagentTranscript) + } + + @Test + fun testTailError_setsRetryableErrorState() = + testScope.runTest { + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1", + status = "running", + ), + ), + ) + + coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } throws + RuntimeException("Gateway connection error") + + delegate.inspectSubagentTranscript("sub-1") + advanceTimeBy(100) + + val transcript = uiState.value.subagentTranscript + assertNotNull(transcript) + assertEquals("Gateway connection error", transcript?.error) + assertFalse(transcript?.isLoading == true) + + // Now recover on retry + coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns + SubagentTailResponse(subagentId = "sub-1", text = "Recovered logs") + + delegate.retryTranscript() + advanceTimeBy(100) + + val recovered = uiState.value.subagentTranscript + assertEquals("Recovered logs", recovered?.text) + assertNull(recovered?.error) + + delegate.closeSubagentTranscript() + } +} From 52e0104cd2f28024cf5c0f3b33774a19221359e8 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:41:13 +0300 Subject: [PATCH 38/42] feat(connectors): surface active session integrations (#1091) (#1100) --- .../SessionIntegrationsSheetTest.kt | 413 +++++ .../hermescontrol/data/local/AuthManager.kt | 6 + .../data/model/ConnectorModels.kt | 233 +++ .../hermescontrol/data/ws/ConnectorParser.kt | 389 +++++ .../data/ws/ConnectorRepository.kt | 126 ++ .../hermescontrol/data/ws/HermesWsClient.kt | 88 +- .../m57/hermescontrol/data/ws/WsMethods.kt | 8 + .../ui/chat/ChatConnectorsDelegate.kt | 942 +++++++++++ .../ui/chat/ChatConnectorsViewModel.kt | 84 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 143 +- .../components/SessionIntegrationsSheet.kt | 752 +++++++++ .../util/ConnectorUrlValidator.kt | 60 + app/src/main/res/values-ar/strings.xml | 32 + app/src/main/res/values-ko/strings.xml | 32 + app/src/main/res/values-zh/strings.xml | 32 + app/src/main/res/values/strings.xml | 32 + .../data/ws/ConnectorParserTest.kt | 742 +++++++++ .../data/ws/ConnectorRepositoryTest.kt | 376 +++++ .../ws/HermesWsClientLoggingSecurityTest.kt | 458 ++++++ .../data/ws/WsProfileParamsTest.kt | 20 + .../ui/chat/ChatConnectorsDelegateTest.kt | 1403 +++++++++++++++++ .../ui/chat/ChatConnectorsViewModelTest.kt | 146 ++ .../util/ConnectorUrlValidatorTest.kt | 68 + 23 files changed, 6579 insertions(+), 6 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheetTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorParser.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegate.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheet.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/util/ConnectorUrlValidator.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorParserTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegateTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModelTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/util/ConnectorUrlValidatorTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheetTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheetTest.kt new file mode 100644 index 00000000..69c0072c --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheetTest.kt @@ -0,0 +1,413 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorStatus +import com.m57.hermescontrol.ui.chat.ChatConnectorsUiState +import com.m57.hermescontrol.ui.chat.ConnectorActionState +import com.m57.hermescontrol.ui.chat.ConnectorUiItem +import com.m57.hermescontrol.ui.chat.ConnectorsLoadPhase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class SessionIntegrationsSheetTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testLoadingState_displaysLoadingIndicator() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loading, + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_loading").assertIsDisplayed() + } + + @Test + fun testConnectedItem_displaysConnectedStatusAndReconnectAction() { + var clickedSlug: String? = null + var clickedReconnect: Boolean? = null + + val item = + ConnectorUiItem( + slug = "github", + name = "GitHub", + isConnected = true, + isEnabled = true, + status = ConnectorStatus.CONNECTED, + connectionStatus = "connected", + ) + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + items = listOf(item), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { slug, reconnect -> + clickedSlug = slug + clickedReconnect = reconnect + }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_item_github").assertIsDisplayed() + composeTestRule.onNodeWithText("GitHub").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_action_github").assertIsDisplayed() + + composeTestRule.onNodeWithTag("session_integrations_action_github").performClick() + assertEquals("github", clickedSlug) + assertEquals(true, clickedReconnect) + } + + @Test + fun testDisconnectedItem_displaysConnectActionAndTriggersCallback() { + var clickedSlug: String? = null + var clickedReconnect: Boolean? = null + + val item = + ConnectorUiItem( + slug = "slack", + name = "Slack", + isConnected = false, + isEnabled = true, + status = ConnectorStatus.DISCONNECTED, + connectionStatus = "disconnected", + ) + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + items = listOf(item), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { slug, reconnect -> + clickedSlug = slug + clickedReconnect = reconnect + }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_item_slack").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_action_slack").assertIsDisplayed() + + composeTestRule.onNodeWithTag("session_integrations_action_slack").performClick() + assertEquals("slack", clickedSlug) + assertEquals(false, clickedReconnect) + } + + @Test + fun testConnectingState_displaysSpinnerAndDisablesActions() { + val item = + ConnectorUiItem( + slug = "google", + name = "Google Drive", + isConnected = false, + isEnabled = true, + status = ConnectorStatus.DISCONNECTED, + connectionStatus = "disconnected", + ) + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + items = listOf(item), + actionState = ConnectorActionState.Connecting(slug = "google", isReconnect = false), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_spinner_google").assertIsDisplayed() + } + + @Test + fun testAwaitingAuthorizationState_displaysBannerAndRefreshStatus() { + var refreshed = false + val item = + ConnectorUiItem( + slug = "jira", + name = "Jira", + isConnected = false, + isEnabled = true, + status = ConnectorStatus.INITIATED, + connectionStatus = "initiated", + ) + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + items = listOf(item), + actionState = ConnectorActionState.AwaitingAuthorization(slug = "jira"), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = { refreshed = true }, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_awaiting_banner").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_refresh_status_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_refresh_status_button").performClick() + assertTrue(refreshed) + } + + @Test + fun testUnavailablePhase_displaysErrorMessage() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Unavailable("Connectors unavailable"), + items = emptyList(), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed() + } + + @Test + fun testErrorPhase_displaysRetryButtonAndTriggersRefresh() { + var retryTriggered = false + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Error(ConnectorError.NetworkError(), canRetry = true), + items = emptyList(), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = { retryTriggered = true }, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_retry_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_retry_button").performClick() + assertTrue(retryTriggered) + } + + @Test + fun testEmptyState_displaysNoIntegrationsMessage() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + items = emptyList(), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_empty").assertIsDisplayed() + } + + @Test + fun testNoSessionState_displaysNoActiveSession() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Initial, + items = emptyList(), + ), + sessionId = null, + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_no_session_state").assertIsDisplayed() + } + + @Test + fun testNoStorageButRuntimeSession_usesAuthoritativeRuntimeSessionId() { + val item = + ConnectorUiItem( + slug = "linear", + name = "Linear", + isConnected = false, + isEnabled = true, + status = ConnectorStatus.DISCONNECTED, + connectionStatus = "disconnected", + ) + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + sessionId = "sess-runtime-999", + items = listOf(item), + ), + sessionId = null, + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_item_linear").assertIsDisplayed() + } + + @Test + fun testUnsupportedBackend_displaysErrorMessage() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.UnsupportedBackend("Older server does not support connectors"), + items = emptyList(), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed() + } + + @Test + fun testUnsupportedRuntime_displaysErrorMessage() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.UnsupportedRuntime("Runtime incompatible"), + items = emptyList(), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed() + } + + @Test + fun testNotOwner_displaysErrorMessage() { + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.NotOwner("Session owned by another user"), + items = emptyList(), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed() + } + + @Test + fun testExpiredAndRevokedItems_displayStatusAndAction() { + val expiredItem = + ConnectorUiItem( + slug = "github", + name = "GitHub", + isConnected = false, + isEnabled = true, + status = ConnectorStatus.EXPIRED, + connectionStatus = "expired", + ) + val revokedItem = + ConnectorUiItem( + slug = "jira", + name = "Jira", + isConnected = false, + isEnabled = true, + status = ConnectorStatus.REVOKED, + connectionStatus = "revoked", + ) + + composeTestRule.setContent { + SessionIntegrationsContent( + uiState = + ChatConnectorsUiState( + loadPhase = ConnectorsLoadPhase.Loaded(available = true), + items = listOf(expiredItem, revokedItem), + ), + sessionId = "session-12345", + onDismiss = {}, + onRefresh = {}, + onConnect = { _, _ -> }, + onClearError = {}, + ) + } + + composeTestRule.onNodeWithTag("session_integrations_item_github").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_action_github").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_item_jira").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_integrations_action_jira").assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index ac2e6d77..33a15a4f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.local import android.content.Context import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKeys import com.m57.hermescontrol.data.config.ConnectionProfile @@ -500,6 +501,11 @@ object AuthManager { _baseUrlFlow.value = "" } + @VisibleForTesting + fun setBaseUrlForTest(baseUrl: String) { + _baseUrlFlow.value = baseUrl + } + fun getToken(): String? { if (tokenInitialized) return cachedToken synchronized(this) { diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt new file mode 100644 index 00000000..13d16db0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt @@ -0,0 +1,233 @@ +package com.m57.hermescontrol.data.model + +import com.m57.hermescontrol.util.ConnectorUrlValidator + +/** + * High-level connection status for a connector item. + * Deliberate precedence: if [connected] is true, status resolves to [CONNECTED] + * regardless of expired/revoked/stale statuses reported by backend. + */ +enum class ConnectorStatus { + CONNECTED, + INITIATED, + EXPIRED, + REVOKED, + FAILED, + DISCONNECTED, + UNKNOWN, + ; + + val isConnected: Boolean get() = this == CONNECTED + + companion object { + fun from( + connected: Boolean, + rawStatus: String?, + ): ConnectorStatus = from(connected = connected as Boolean?, rawStatus = rawStatus) + + fun from( + connected: Boolean?, + rawStatus: String?, + ): ConnectorStatus { + if (connected == true) return CONNECTED + if (connected == false) { + return when (rawStatus?.lowercase()?.trim()) { + "expired" -> EXPIRED + "revoked" -> REVOKED + "initiated" -> INITIATED + "failed", "error" -> FAILED + "disconnected" -> DISCONNECTED + null, "", "active", "connected", "authorized" -> DISCONNECTED + else -> UNKNOWN + } + } + // Missing boolean: "authorized", "active", or "connected" yields effective CONNECTED + return when (rawStatus?.lowercase()?.trim()) { + "authorized", "active", "connected" -> CONNECTED + "expired" -> EXPIRED + "revoked" -> REVOKED + "initiated" -> INITIATED + "failed", "error" -> FAILED + "disconnected" -> DISCONNECTED + null, "" -> DISCONNECTED + else -> UNKNOWN + } + } + } +} + +/** + * Status resulting from a connect/reconnect initiation. + */ +enum class ConnectorConnectStatus { + ACTIVE, + INITIATED, + FAILED, + UNKNOWN, + ; + + val isActive: Boolean get() = this == ACTIVE + + companion object { + fun from(rawStatus: String?): ConnectorConnectStatus = + when (rawStatus?.lowercase()?.trim()) { + "active", "connected" -> ACTIVE + "initiated" -> INITIATED + "failed", "error" -> FAILED + null, "" -> UNKNOWN + else -> UNKNOWN + } + } +} + +/** + * Individual connector item returned by `connectors.list`. + */ +data class ConnectorItem( + val connector: String, + val name: String = connector, + val connected: Boolean = false, + val enabled: Boolean = true, + val connectionStatus: String? = null, + val status: ConnectorStatus = ConnectorStatus.from(connected, connectionStatus), + val connectUrl: String? = null, +) { + val isConnected: Boolean get() = connected || status.isConnected + + /** Safe validated HTTPS URL, or null if invalid or absent. */ + val safeConnectUrl: String? + get() = if (ConnectorUrlValidator.isValidHttpsUrl(connectUrl)) connectUrl else null + + override fun toString(): String { + val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null" + return "ConnectorItem(" + + "connector=$connector, " + + "name=$name, " + + "connected=$connected, " + + "enabled=$enabled, " + + "status=$status, " + + "connectUrl=$redactedUrl" + + ")" + } +} + +/** + * Result of a connect/reconnect action for an individual connector. + * Instructions from arbitrary backends are intentionally not exposed for UI display. + */ +data class ConnectorConnectItem( + val connector: String, + val status: ConnectorConnectStatus, + val connectUrl: String? = null, +) { + val isConnectUrlValid: Boolean + get() = ConnectorUrlValidator.isValidHttpsUrl(connectUrl) + + val safeConnectUrl: String? + get() = if (isConnectUrlValid) connectUrl else null + + override fun toString(): String { + val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null" + return "ConnectorConnectItem(" + + "connector=$connector, " + + "status=$status, " + + "connectUrl=$redactedUrl, " + + "isConnectUrlValid=$isConnectUrlValid" + + ")" + } +} + +/** + * Summary counts returned by `connectors.connect`. + */ +data class ConnectorConnectSummary( + val total: Int = 0, + val active: Int = 0, + val initiated: Int = 0, + val failed: Int = 0, +) + +/** + * Typed safe connector errors mapped from RPC codes and reasons. + * Raw exceptions or auth tokens are never exposed. + */ +sealed interface ConnectorError { + val message: String + + data class InvalidParams( + override val message: String = "Invalid connector parameters.", + ) : ConnectorError + + data class NotOwner( + override val message: String = "Session is not owned by this transport.", + ) : ConnectorError + + data class Unavailable( + override val message: String = "Connectors are not available in this session.", + ) : ConnectorError + + data class UnsupportedRuntime( + override val message: String = "Connectors must be managed on the session's compute host.", + ) : ConnectorError + + data class RequestFailed( + override val message: String = "Connector request failed. Please try again.", + ) : ConnectorError + + data class InvalidResponse( + override val message: String = "Connector service returned an invalid response.", + ) : ConnectorError + + data class UnsupportedBackend( + override val message: String = "Connector service is not supported by this backend.", + ) : ConnectorError + + data class MalformedEnvelope( + override val message: String = "Malformed response envelope received from gateway.", + ) : ConnectorError + + data class NetworkError( + override val message: String = "Failed to communicate with gateway.", + ) : ConnectorError + + data class Other( + val code: Int = 0, + override val message: String = "Connector operation failed.", + ) : ConnectorError +} + +/** + * Result envelope for `connectors.list`. + */ +sealed interface ConnectorListResult { + data class Success( + val available: Boolean, + val connectors: List, + ) : ConnectorListResult + + data class Error( + val error: ConnectorError, + ) : ConnectorListResult + + fun getOrNull(): List? = (this as? Success)?.connectors + + fun errorOrNull(): ConnectorError? = (this as? Error)?.error +} + +/** + * Result envelope for `connectors.connect`. + */ +sealed interface ConnectorConnectResult { + data class Success( + val results: List, + val summary: ConnectorConnectSummary, + ) : ConnectorConnectResult + + data class Error( + val error: ConnectorError, + ) : ConnectorConnectResult + + fun getOrNull(): List? = (this as? Success)?.results + + fun errorOrNull(): ConnectorError? = (this as? Error)?.error +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorParser.kt new file mode 100644 index 00000000..2e150d59 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorParser.kt @@ -0,0 +1,389 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ConnectorConnectItem +import com.m57.hermescontrol.data.model.ConnectorConnectResult +import com.m57.hermescontrol.data.model.ConnectorConnectStatus +import com.m57.hermescontrol.data.model.ConnectorConnectSummary +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorItem +import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.model.ConnectorStatus +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull + +/** + * Dedicated parser for connector payloads with strict envelope validation, + * resilient type coercion, legacy alias normalization, and safe error mapping. + */ +object ConnectorParser { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Parse raw result from `connectors.list` RPC. + * Fails with [ConnectorError.MalformedEnvelope] if the required envelope structure is missing. + */ + fun parseListResult(raw: Any?): ConnectorListResult { + val root = + toJsonElement(raw) as? JsonObject + ?: return ConnectorListResult.Error( + ConnectorError.MalformedEnvelope("Response envelope must be a JSON object."), + ) + + // Required envelope check: "connectors" must exist and be an array. + val connectorsArray = + root["connectors"] as? JsonArray + ?: return ConnectorListResult.Error( + ConnectorError.MalformedEnvelope("Envelope missing required 'connectors' array."), + ) + + // Optional 'available' flag (defaults to true if omitted, unless explicitly false) + val available = parseBoolean(root["available"], default = true) + + val items = mutableListOf() + val seenSlugs = mutableSetOf() + for (element in connectorsArray) { + val itemObj = element as? JsonObject ?: continue + val item = parseConnectorItem(itemObj) ?: continue + if (seenSlugs.add(item.connector)) { + items.add(item) + } + } + + // Entirely invalid nonempty connectors array must fail rather than looking silently valid empty. + if (connectorsArray.isNotEmpty() && items.isEmpty()) { + return ConnectorListResult.Error( + ConnectorError.MalformedEnvelope("Connectors list contains only invalid items."), + ) + } + + return ConnectorListResult.Success( + available = available, + connectors = items, + ) + } + + /** + * Parse raw result from `connectors.connect` RPC. + * Fails with [ConnectorError.MalformedEnvelope] or [ConnectorError.InvalidResponse] if invalid. + */ + fun parseConnectResult(raw: Any?): ConnectorConnectResult { + val rawElement = toJsonElement(raw) + + // Tolerate purposeful legacy bare array form: [ { "connector": "linear", ... } ] + if (rawElement is JsonArray) { + val results = mutableListOf() + val seenSlugs = mutableSetOf() + for (element in rawElement) { + val itemObj = element as? JsonObject ?: continue + val item = parseConnectItem(itemObj) ?: continue + if (seenSlugs.add(item.connector)) { + results.add(item) + } + } + if (results.isNotEmpty()) { + val summary = + ConnectorConnectSummary( + total = results.size, + active = results.count { it.status == ConnectorConnectStatus.ACTIVE }, + initiated = results.count { it.status == ConnectorConnectStatus.INITIATED }, + failed = results.count { it.status == ConnectorConnectStatus.FAILED }, + ) + return ConnectorConnectResult.Success( + results = results, + summary = summary, + ) + } + return ConnectorConnectResult.Error( + ConnectorError.InvalidResponse("Connect response contains no valid results."), + ) + } + + val root = + rawElement as? JsonObject + ?: return ConnectorConnectResult.Error( + ConnectorError.MalformedEnvelope("Connect response must be a JSON object."), + ) + + // Required envelope check: "results" must exist and be an array. + val resultsArray = + root["results"] as? JsonArray + ?: return ConnectorConnectResult.Error( + ConnectorError.MalformedEnvelope("Connect envelope missing required 'results' array."), + ) + + val results = mutableListOf() + val seenSlugs = mutableSetOf() + for (element in resultsArray) { + val itemObj = element as? JsonObject ?: continue + val item = parseConnectItem(itemObj) ?: continue + if (seenSlugs.add(item.connector)) { + results.add(item) + } + } + + // Connect must return at least nonempty valid results + if (results.isEmpty()) { + return ConnectorConnectResult.Error( + ConnectorError.InvalidResponse("Connect response contains no valid results."), + ) + } + + // Current summary object is required in standard envelope + val summaryObj = + root["summary"] as? JsonObject + ?: return ConnectorConnectResult.Error( + ConnectorError.MalformedEnvelope("Connect envelope missing required 'summary' object."), + ) + val summary = parseConnectSummary(summaryObj) + + return ConnectorConnectResult.Success( + results = results, + summary = summary, + ) + } + + private fun parseConnectorItem(obj: JsonObject): ConnectorItem? { + // Strict string identifier with backend slug validation; legacy aliases supported + val slug = + parseIdentifier(obj["connector"]) + ?: parseIdentifier(obj["slug"]) + ?: parseIdentifier(obj["id"]) + ?: return null // Skip items without valid connector identifier + + // Display name: primary "name", legacy aliases "displayName", "title", fallback to slug + val name = + parseString(obj["name"]) + ?: parseString(obj["displayName"]) + ?: parseString(obj["title"]) + ?: slug + + val rawConnected = parseBooleanOrNull(obj["connected"]) + val enabled = parseBoolean(obj["enabled"], default = true) + + // Connection status string: primary "connectionStatus", legacy aliases "status", "connection_status" + val rawStatus = + parseString(obj["connectionStatus"]) + ?: parseString(obj["status"]) + ?: parseString(obj["connection_status"]) + + // Compute typed status respecting connected precedence: + // When connected boolean is missing (legacy), status "authorized"/"connected"/"active" yields CONNECTED. + // Explicit connected=false is authoritative and not overridden by status active. + val status = ConnectorStatus.from(connected = rawConnected, rawStatus = rawStatus) + val effectiveConnected = rawConnected ?: status.isConnected + + // Connect URL (transient): primary "connect_url", legacy aliases "connectUrl", "auth_url", "authUrl" + val connectUrl = + parseString(obj["connect_url"]) + ?: parseString(obj["connectUrl"]) + ?: parseString(obj["auth_url"]) + ?: parseString(obj["authUrl"]) + + return ConnectorItem( + connector = slug, + name = name, + connected = effectiveConnected, + enabled = enabled, + connectionStatus = rawStatus, + status = status, + connectUrl = connectUrl, + ) + } + + private fun parseConnectItem(obj: JsonObject): ConnectorConnectItem? { + val slug = + parseIdentifier(obj["connector"]) + ?: parseIdentifier(obj["slug"]) + ?: parseIdentifier(obj["id"]) + ?: return null + + val rawStatus = + parseString(obj["status"]) + ?: parseString(obj["connectionStatus"]) + ?: parseString(obj["connection_status"]) + + val status = ConnectorConnectStatus.from(rawStatus) + + val connectUrl = + parseString(obj["connect_url"]) + ?: parseString(obj["connectUrl"]) + ?: parseString(obj["auth_url"]) + ?: parseString(obj["authUrl"]) + + return ConnectorConnectItem( + connector = slug, + status = status, + connectUrl = connectUrl, + ) + } + + private fun parseConnectSummary(obj: JsonObject?): ConnectorConnectSummary { + if (obj == null) return ConnectorConnectSummary() + return ConnectorConnectSummary( + total = parseInt(obj["total"], default = 0), + active = parseInt(obj["active"], default = 0), + initiated = parseInt(obj["initiated"], default = 0), + failed = parseInt(obj["failed"], default = 0), + ) + } + + /** + * Map RPC error codes and reasons into typed [ConnectorError]s. + * Raw server strings and sensitive parameters are never forwarded or exposed in error messages. + */ + fun mapRpcError( + code: Int, + message: String? = null, + data: JsonElement? = null, + ): ConnectorError { + val reason = + when (data) { + is JsonObject -> (data["reason"] as? JsonPrimitive)?.content + is JsonPrimitive -> data.content + else -> null + }?.uppercase()?.trim() + + return when { + code == 4000 || reason == "INVALID_PARAMS" -> { + ConnectorError.InvalidParams() + } + + code == 4001 || reason == "NOT_OWNER" -> { + ConnectorError.NotOwner() + } + + code == 4031 || reason == "CONNECTORS_UNAVAILABLE" -> { + ConnectorError.Unavailable() + } + + code == 5033 || reason == "UNSUPPORTED_RUNTIME" -> { + ConnectorError.UnsupportedRuntime() + } + + code == 5034 && reason == "INVALID_CONNECTOR_RESPONSE" -> { + ConnectorError.InvalidResponse() + } + + code == 5034 -> { + ConnectorError.RequestFailed() + } + + code == -32601 -> { + ConnectorError.UnsupportedBackend() + } + + else -> { + ConnectorError.Other(code = code) + } + } + } + + internal fun parseStrictString(element: JsonElement?): String? { + if (element == null || element is JsonNull) return null + if (element is JsonPrimitive && element.isString) { + return element.content + } + return null + } + + internal fun parseIdentifier(element: JsonElement?): String? { + val str = parseStrictString(element)?.trim() ?: return null + return if (ConnectorRepository.isValidSlug(str)) str else null + } + + internal fun parseString(element: JsonElement?): String? { + if (element == null || element is JsonNull) return null + if (element is JsonPrimitive) { + if (!element.isString && element.content.isBlank()) return null + return element.content + } + return null + } + + internal fun parseBooleanOrNull(element: JsonElement?): Boolean? { + if (element == null || element is JsonNull) return null + if (element is JsonPrimitive) { + element.booleanOrNull?.let { return it } + val content = element.content.trim() + if (content.equals("true", ignoreCase = true) || content == "1") return true + if (content.equals("false", ignoreCase = true) || content == "0") return false + } + return null + } + + internal fun parseBoolean( + element: JsonElement?, + default: Boolean, + ): Boolean = parseBooleanOrNull(element) ?: default + + internal fun parseInt( + element: JsonElement?, + default: Int, + ): Int { + if (element == null || element is JsonNull) return default + if (element is JsonPrimitive) { + element.intOrNull?.let { return it } + element.content.toIntOrNull()?.let { return it } + } + return default + } + + fun toJsonElement(value: Any?): JsonElement = + when (value) { + null -> { + JsonNull + } + + is JsonElement -> { + value + } + + is Map<*, *> -> { + JsonObject( + @Suppress("UNCHECKED_CAST") + (value as Map).mapValues { (_, v) -> toJsonElement(v) }, + ) + } + + is List<*> -> { + JsonArray(value.map { toJsonElement(it) }) + } + + is String -> { + val trimmed = value.trim() + if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + try { + json.parseToJsonElement(value) + } catch (_: Exception) { + JsonPrimitive(value) + } + } else { + JsonPrimitive(value) + } + } + + is Boolean -> { + JsonPrimitive(value) + } + + is Number -> { + JsonPrimitive(value) + } + + else -> { + JsonPrimitive(value.toString()) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt new file mode 100644 index 00000000..1c39ce40 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt @@ -0,0 +1,126 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ConnectorConnectResult +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorListResult +import kotlinx.coroutines.CancellationException + +/** + * Repository interface for connector operations: `connectors.list` and `connectors.connect` (issue #1091). + * Public injectable interface enabling isolated testing in ViewModels and controllers. + */ +interface ConnectorRepository { + /** + * Request connector catalog and authorization status for [sessionId]. + */ + suspend fun listConnectors(sessionId: String): ConnectorListResult + + /** + * Initiate authorization flow for [connectors] slugs in [sessionId]. + * If [reconnect] is true, restarts authorization flow even if already connected. + */ + suspend fun connect( + sessionId: String, + connectors: List, + reconnect: Boolean = false, + ): ConnectorConnectResult + + companion object : ConnectorRepository by HermesConnectorRepository() { + private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]*$") + + fun isValidSlug(slug: String?): Boolean { + if (slug.isNullOrBlank()) return false + return SLUG_REGEX.matches(slug) + } + } +} + +/** + * Default implementation of [ConnectorRepository] backed by [HermesWsClient]. + */ +class HermesConnectorRepository( + private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params -> + val deferred = HermesWsClient.request(method, params) + try { + deferred.await() + } catch (e: CancellationException) { + deferred.cancel(e) + throw e + } finally { + if (!deferred.isCompleted) { + deferred.cancel() + } + } + }, +) : ConnectorRepository { + override suspend fun listConnectors(sessionId: String): ConnectorListResult { + if (sessionId.isBlank()) { + return ConnectorListResult.Error( + ConnectorError.InvalidParams("session_id required"), + ) + } + + return try { + val params = mapOf("session_id" to sessionId) + val result = rpcRequest(WsMethods.CONNECTORS_LIST, params) + ConnectorParser.parseListResult(result) + } catch (e: CancellationException) { + throw e + } catch (e: HermesWsClient.HermesRpcException) { + ConnectorListResult.Error( + ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data), + ) + } catch (_: Exception) { + ConnectorListResult.Error( + ConnectorError.NetworkError("Failed to communicate with gateway."), + ) + } + } + + override suspend fun connect( + sessionId: String, + connectors: List, + reconnect: Boolean, + ): ConnectorConnectResult { + if (sessionId.isBlank()) { + return ConnectorConnectResult.Error( + ConnectorError.InvalidParams("session_id required"), + ) + } + + if (connectors.isEmpty()) { + return ConnectorConnectResult.Error( + ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"), + ) + } + + for (slug in connectors) { + if (!ConnectorRepository.isValidSlug(slug)) { + return ConnectorConnectResult.Error( + ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"), + ) + } + } + + return try { + val params = + mapOf( + "session_id" to sessionId, + "connectors" to connectors, + "reconnect" to reconnect, + ) + val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params) + ConnectorParser.parseConnectResult(result) + } catch (e: CancellationException) { + throw e + } catch (e: HermesWsClient.HermesRpcException) { + ConnectorConnectResult.Error( + ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data), + ) + } catch (_: Exception) { + ConnectorConnectResult.Error( + ConnectorError.NetworkError("Failed to communicate with gateway."), + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index cca84223..4a2c7515 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.session.ActiveSessionHolder +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -30,6 +31,8 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response @@ -702,6 +705,8 @@ object HermesWsClient { /** Thrown when an awaited [request] is neither answered nor rejected within [REQUEST_TIMEOUT_MS], or is rejected by a disconnect. */ class HermesRpcException( message: String, + val code: Int = 0, + val data: JsonElement? = null, ) : Exception(message) /** @@ -741,6 +746,15 @@ object HermesWsClient { send(method, params) { reqId -> pendingCalls[reqId] = PendingCall(method, deferred) } + deferred.invokeOnCompletion { cause -> + if (cause is CancellationException) { + pendingCalls.remove(id)?.let { call -> + call.timeoutJob?.cancel() + removeQueuedMessage(id) + disconnectIfIdleInBackground() + } + } + } // Arm the per-request timeout (fires if the server never answers). pendingCalls[id]?.timeoutJob = wsScope.launch { @@ -760,7 +774,13 @@ object HermesWsClient { removeQueuedMessage(id) call.timeoutJob?.cancel() if (error != null) { - call.deferred.completeExceptionally(HermesRpcException(error.message)) + call.deferred.completeExceptionally( + HermesRpcException( + message = error.message, + code = error.code, + data = error.data, + ), + ) } else { call.deferred.complete(result) } @@ -808,7 +828,9 @@ object HermesWsClient { params = decoratedParams.mapValues { it.value.toJsonElement() }, ) val json = OkHttpProvider.json.encodeToString(request) - if (BuildConfig.DEBUG) Log.d(TAG, "→ $json") + if (BuildConfig.DEBUG) { + Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false)) + } var reconnect = false synchronized(outboundLock) { if (method == WsMethods.PROMPT_SUBMIT) { @@ -1191,7 +1213,7 @@ object HermesWsClient { markQueuedMessageSent(msg) continue } - if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg") + if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg)) if (!webSocket.send(msg)) { recoverRejectedSocket(webSocket) break @@ -1209,7 +1231,7 @@ object HermesWsClient { text: String, ) { if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return - if (BuildConfig.DEBUG) Log.d(TAG, "← $text") + if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text)) lastPongTimestamp = monotonicTimeMs() // Resolve any in-flight `request()` awaiting this RPC result/error // (issue #526) before fanning the parsed event out to collectors. @@ -1269,7 +1291,7 @@ object HermesWsClient { parsed } } catch (e: Exception) { - Log.e(TAG, "Failed to parse message", e) + Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}") WsEvent.Unknown(text) } when (event) { @@ -1381,4 +1403,60 @@ object HermesWsClient { } } } + + @VisibleForTesting + internal fun setConnectionStatusForTest(status: ConnectionStatus) { + _connectionStatus.value = status + } + + @VisibleForTesting + internal fun formatSafeOutgoingFrameLog( + id: String, + method: String, + paramsKeys: Collection?, + byteLength: Int, + queued: Boolean = false, + ): String { + val prefix = if (queued) "→ (queued)" else "→" + val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]" + return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength" + } + + @VisibleForTesting + internal fun formatSafeQueuedFrameLog(msg: String): String = + try { + val req = OkHttpProvider.json.decodeFromString(msg) + formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true) + } catch (_: Throwable) { + "→ (queued) frame bytes=${msg.length}" + } + + @VisibleForTesting + internal fun formatSafeIncomingFrameLog(text: String): String = + try { + val element = OkHttpProvider.json.parseToJsonElement(text) + if (element is JsonObject) { + val id = (element["id"] as? JsonPrimitive)?.content + val method = (element["method"] as? JsonPrimitive)?.content + val hasResult = element.containsKey("result") + val resultKeys = + (element["result"] as? JsonObject)?.keys?.joinToString( + ",", + prefix = "[", + postfix = "]", + ) + val errorObj = element["error"] as? JsonObject + val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content + val sb = StringBuilder("← ") + if (id != null) sb.append("id=").append(id).append(" ") + if (method != null) sb.append("method=").append(method).append(" ") + if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ") + if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ") + sb.append("bytes=").append(text.length).toString() + } else { + "← non-object frame bytes=${text.length}" + } + } catch (_: Throwable) { + "← frame bytes=${text.length}" + } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 088ebccf..4c0a68c9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -91,6 +91,14 @@ object WsMethods { /** Rolling tail snapshot of a subagent's live execution transcript. */ const val SUBAGENT_TAIL = "subagent.tail" + // ── Connectors (issue #1091) ─────────────────────────────────────────── + + /** List available third-party connectors and authorization state for a session. */ + const val CONNECTORS_LIST = "connectors.list" + + /** Initiate or restart authorization flow for one or more connectors. */ + const val CONNECTORS_CONNECT = "connectors.connect" + // ── Billing / subscription (issue #628) ───────────────────────────── // Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e). // `credits.view` was REMOVED upstream; these replace it. `usage.bars` diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegate.kt new file mode 100644 index 00000000..effd5442 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegate.kt @@ -0,0 +1,942 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.ConnectorConnectResult +import com.m57.hermescontrol.data.model.ConnectorConnectStatus +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorItem +import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.model.ConnectorStatus +import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.ConnectorRepository +import com.m57.hermescontrol.util.ConnectorUrlValidator +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Explicit load phases for connector list rendering. + */ +sealed interface ConnectorsLoadPhase { + data object Initial : ConnectorsLoadPhase + + data object Loading : ConnectorsLoadPhase + + data class Refreshing( + val previousItems: List, + ) : ConnectorsLoadPhase + + data class Loaded( + val available: Boolean, + ) : ConnectorsLoadPhase + + data class Error( + val error: ConnectorError, + val canRetry: Boolean = true, + ) : ConnectorsLoadPhase + + data class Unavailable( + val message: String, + ) : ConnectorsLoadPhase + + data class UnsupportedRuntime( + val message: String, + ) : ConnectorsLoadPhase + + data class UnsupportedBackend( + val message: String, + ) : ConnectorsLoadPhase + + data class NotOwner( + val message: String, + ) : ConnectorsLoadPhase +} + +/** + * Mutating action phases for individual connector operations. + */ +sealed interface ConnectorActionState { + data object Idle : ConnectorActionState + + data class Connecting( + val slug: String, + val isReconnect: Boolean, + ) : ConnectorActionState + + data class AwaitingAuthorization( + val slug: String, + ) : ConnectorActionState +} + +/** + * Immutable safe UI item representation. Raw connect URLs are strictly omitted. + */ +data class ConnectorUiItem( + val slug: String, + val name: String, + val isConnected: Boolean, + val isEnabled: Boolean, + val status: ConnectorStatus, + val connectionStatus: String?, +) { + companion object { + fun from(item: ConnectorItem): ConnectorUiItem = + ConnectorUiItem( + slug = item.connector, + name = item.name, + isConnected = item.connected, + isEnabled = item.enabled, + status = item.status, + connectionStatus = item.connectionStatus, + ) + } +} + +/** + * Transient one-shot browser launch event. Redacts URL in [toString]. + */ +data class ConnectBrowserEvent( + val slug: String, + val url: String, + val eventId: Long = System.nanoTime(), + val sessionId: String? = null, + val generation: Long = 0L, +) { + override fun toString(): String = + "ConnectBrowserEvent(slug=$slug, url=[REDACTED], eventId=$eventId, sessionId=$sessionId, generation=$generation)" +} + +/** + * Complete UI state exposed to the session integrations view. + */ +data class ChatConnectorsUiState( + val isVisible: Boolean = false, + val loadPhase: ConnectorsLoadPhase = ConnectorsLoadPhase.Initial, + val items: List = emptyList(), + val actionState: ConnectorActionState = ConnectorActionState.Idle, + val browserLaunchEvent: ConnectBrowserEvent? = null, + val errorMessage: String? = null, + val isOldBackend: Boolean = false, + val sessionId: String? = null, + val generation: Long = 0L, +) { + val isLoading: Boolean + get() = loadPhase is ConnectorsLoadPhase.Loading || actionState is ConnectorActionState.Connecting +} + +/** + * Controller / Delegate managing session Integrations (connectors) state, + * lifecycle, deduplication, and authorization flows (issue #1091). + */ +class ChatConnectorsDelegate( + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val repository: ConnectorRepository = ConnectorRepository, + private val runtimeSessionId: () -> String? = { ActiveSessionHolder.activeSessionId.value }, +) { + private val _uiState = MutableStateFlow(ChatConnectorsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var isForeground: Boolean = false + private var sessionGeneration: Long = 0L + private var refreshRequestId: Long = 0L + private var refreshJob: Job? = null + private var connectJob: Job? = null + private var pendingRefreshAfterMutation: Boolean = false + private var pendingTrailingRefresh: Boolean = false + private var pendingBrowserEvent: ConnectBrowserEvent? = null + private var browserLaunched: Boolean = false + + /** + * Show integrations panel and trigger load if in foreground. + */ + fun show() { + if (_uiState.value.isVisible) return + val sid = runtimeSessionId() + _uiState.update { + it.copy( + isVisible = true, + sessionId = sid, + generation = sessionGeneration, + ) + } + if (isForeground) { + if (!sid.isNullOrBlank() && !_uiState.value.isOldBackend) { + refresh(force = false) + } + } + } + + /** + * Hide integrations panel, cancelling ongoing jobs, incrementing generation, + * and clearing transient state. + */ + fun hide() { + sessionGeneration++ + refreshJob?.cancel() + refreshJob = null + connectJob?.cancel() + connectJob = null + pendingRefreshAfterMutation = false + pendingTrailingRefresh = false + pendingBrowserEvent = null + browserLaunched = false + _uiState.update { + it.copy( + isVisible = false, + actionState = ConnectorActionState.Idle, + browserLaunchEvent = null, + generation = sessionGeneration, + ) + } + } + + /** + * Lifecycle resume hook. Refreshes once when returning from external browser authorization. + * Guarded to only execute on true paused -> resume transitions. + */ + fun onResume() { + val wasForeground = isForeground + isForeground = true + if (wasForeground) return + if (!_uiState.value.isVisible) return + + val sid = runtimeSessionId() + val pending = pendingBrowserEvent + if (pending != null) { + pendingBrowserEvent = null + if (pending.generation == sessionGeneration && + pending.sessionId == sid && + _uiState.value.actionState is ConnectorActionState.AwaitingAuthorization + ) { + _uiState.update { it.copy(browserLaunchEvent = pending) } + } + } + + if (pendingRefreshAfterMutation || pendingTrailingRefresh) { + pendingRefreshAfterMutation = false + pendingTrailingRefresh = false + refresh(force = true) + return + } + + val currentAction = _uiState.value.actionState + if (currentAction is ConnectorActionState.AwaitingAuthorization) { + if (browserLaunched) { + // Actual return from external browser authorization: refresh once to confirm authorization + browserLaunched = false + refresh(force = true) + return + } + // Minted unconsumed event waiting to be launched: do NOT prematurely refresh + return + } + + // Returned from background: initial load or reload interrupted read if needed and not old backend + val currentPhase = _uiState.value.loadPhase + val needsCatalogLoad = + ( + currentPhase is ConnectorsLoadPhase.Initial || + currentPhase is ConnectorsLoadPhase.Loading || + currentPhase is ConnectorsLoadPhase.Refreshing + ) && + refreshJob?.isActive != true && + !_uiState.value.isOldBackend + + if (needsCatalogLoad) { + refresh(force = false) + } + } + + /** + * Lifecycle pause hook. Retains unconsumed browser event bound to session + generation. + */ + fun onPause() { + isForeground = false + val unconsumed = _uiState.value.browserLaunchEvent ?: pendingBrowserEvent + if (unconsumed != null && + unconsumed.generation == sessionGeneration && + unconsumed.sessionId == runtimeSessionId() + ) { + pendingBrowserEvent = unconsumed + } + _uiState.update { it.copy(browserLaunchEvent = null) } + } + + /** + * Notify of active session change with generation guard (A -> B -> A). + */ + fun onActiveSessionChanged(newSessionId: String?) { + sessionGeneration++ + val currentGen = sessionGeneration + + refreshJob?.cancel() + refreshJob = null + connectJob?.cancel() + connectJob = null + pendingRefreshAfterMutation = false + pendingTrailingRefresh = false + pendingBrowserEvent = null + browserLaunched = false + + if (newSessionId.isNullOrBlank()) { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.Initial, + items = emptyList(), + actionState = ConnectorActionState.Idle, + browserLaunchEvent = null, + errorMessage = null, + isOldBackend = false, + sessionId = null, + generation = currentGen, + ) + } + return + } + + if (_uiState.value.isVisible && isForeground) { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.Loading, + items = emptyList(), + actionState = ConnectorActionState.Idle, + browserLaunchEvent = null, + errorMessage = null, + isOldBackend = false, + sessionId = newSessionId, + generation = currentGen, + ) + } + executeRefresh(newSessionId, currentGen) + } else { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.Initial, + items = emptyList(), + actionState = ConnectorActionState.Idle, + browserLaunchEvent = null, + errorMessage = null, + isOldBackend = false, + sessionId = newSessionId, + generation = currentGen, + ) + } + } + } + + /** + * Transport reconnected notification. Refreshes if visible, foreground, and backend supports connectors. + */ + fun onTransportReconnected() { + if (_uiState.value.isVisible && isForeground && !_uiState.value.isOldBackend) { + val sessionId = runtimeSessionId() + if (!sessionId.isNullOrBlank()) { + refresh(force = true) + } + } + } + + /** + * Request connector catalog and authorization status. + * Deduplicated and coalesced for both normal and forced calls, and blocked in background. + */ + fun refresh(force: Boolean = false) { + if (!_uiState.value.isVisible || !isForeground) return + if (_uiState.value.isOldBackend && !force) return + + val sessionId = runtimeSessionId() + if (sessionId.isNullOrBlank()) return + + if (_uiState.value.actionState is ConnectorActionState.Connecting) { + pendingRefreshAfterMutation = true + return + } + + if (refreshJob?.isActive == true) { + if (force) { + pendingTrailingRefresh = true + } + return + } + + val gen = sessionGeneration + val previousItems = _uiState.value.items + _uiState.update { current -> + if (previousItems.isEmpty()) { + current.copy( + loadPhase = ConnectorsLoadPhase.Loading, + sessionId = sessionId, + generation = gen, + ) + } else { + current.copy( + loadPhase = ConnectorsLoadPhase.Refreshing(previousItems), + sessionId = sessionId, + generation = gen, + ) + } + } + + executeRefresh(sessionId, gen) + } + + private fun executeRefresh( + targetSessionId: String, + generation: Long, + ) { + val requestId = ++refreshRequestId + refreshJob = + scope.launch { + var currentSession = targetSessionId + try { + var result = withContext(ioDispatcher) { repository.listConnectors(currentSession) } + + // Bounded NOT_OWNER reconciliation: re-read active session once (READ ONLY) + if (result is ConnectorListResult.Error && result.error is ConnectorError.NotOwner) { + val freshSession = runtimeSessionId() + if (!freshSession.isNullOrBlank() && freshSession != currentSession) { + currentSession = freshSession + result = withContext(ioDispatcher) { repository.listConnectors(currentSession) } + } + } + + // Check target sid AND generation AND request token AND visibility before applying + val activeSid = runtimeSessionId() + if (requestId != refreshRequestId || + generation != sessionGeneration || + currentSession != activeSid || + !_uiState.value.isVisible + ) { + return@launch + } + + when (result) { + is ConnectorListResult.Success -> { + if (!result.available) { + pendingRefreshAfterMutation = false + pendingTrailingRefresh = false + pendingBrowserEvent = null + _uiState.update { current -> + current.copy( + loadPhase = + ConnectorsLoadPhase.Unavailable( + "Connectors are unavailable for this session.", + ), + items = emptyList(), + actionState = ConnectorActionState.Idle, + browserLaunchEvent = null, + errorMessage = null, + sessionId = currentSession, + generation = generation, + ) + } + return@launch + } + + val uiItems = result.connectors.map { ConnectorUiItem.from(it) } + _uiState.update { current -> + val currentAction = current.actionState + val updatedAction = + if (currentAction is ConnectorActionState.AwaitingAuthorization) { + val matchingItem = uiItems.find { it.slug == currentAction.slug } + // End authorization if connector removed from catalog or now connected + if (matchingItem == null || matchingItem.isConnected) { + ConnectorActionState.Idle + } else { + currentAction + } + } else { + currentAction + } + + current.copy( + loadPhase = ConnectorsLoadPhase.Loaded(result.available), + items = uiItems, + actionState = updatedAction, + isOldBackend = false, + errorMessage = null, // error clears on refresh success + sessionId = currentSession, + generation = generation, + ) + } + } + + is ConnectorListResult.Error -> { + when (val err = result.error) { + is ConnectorError.UnsupportedBackend -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.UnsupportedBackend(err.message), + isOldBackend = true, + items = emptyList(), + actionState = ConnectorActionState.Idle, + sessionId = currentSession, + generation = generation, + ) + } + } + + is ConnectorError.Unavailable -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.Unavailable(err.message), + items = emptyList(), + actionState = ConnectorActionState.Idle, + sessionId = currentSession, + generation = generation, + ) + } + } + + is ConnectorError.UnsupportedRuntime -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.UnsupportedRuntime(err.message), + items = emptyList(), + actionState = ConnectorActionState.Idle, + sessionId = currentSession, + generation = generation, + ) + } + } + + is ConnectorError.NotOwner -> { + val notOwnerMsg = + "Session is not owned by this transport. Please reconnect the session." + _uiState.update { + it.copy( + loadPhase = + ConnectorsLoadPhase.NotOwner( + notOwnerMsg, + ), + items = emptyList(), + actionState = ConnectorActionState.Idle, + sessionId = currentSession, + generation = generation, + ) + } + } + + else -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.Error(err, canRetry = true), + sessionId = currentSession, + generation = generation, + ) + } + } + } + } + } + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + _uiState.update { + it.copy( + loadPhase = + ConnectorsLoadPhase.Error( + ConnectorError.NetworkError(t.message ?: "Failed to load connectors"), + canRetry = true, + ), + sessionId = currentSession, + generation = generation, + ) + } + } finally { + refreshJob = null + if (pendingTrailingRefresh && + generation == sessionGeneration && + _uiState.value.isVisible + ) { + if (isForeground) { + pendingTrailingRefresh = false + refresh(force = true) + } + } else { + pendingTrailingRefresh = false + } + } + } + } + + /** + * Initiate authorization flow for connector [slug]. + * Guarded by visibility, foreground state, catalog readiness, and item enabled status. + * Never replays connect mutations into a different session. + */ + fun connect( + slug: String, + reconnect: Boolean = false, + ) { + if (!_uiState.value.isVisible || !isForeground) return + + if (_uiState.value.isOldBackend || _uiState.value.loadPhase is ConnectorsLoadPhase.UnsupportedBackend) { + _uiState.update { it.copy(errorMessage = "Connectors are not supported by this backend.") } + return + } + + val currentPhase = _uiState.value.loadPhase + if (currentPhase !is ConnectorsLoadPhase.Loaded && currentPhase !is ConnectorsLoadPhase.Refreshing) { + _uiState.update { it.copy(errorMessage = "Cannot connect: connectors catalog is not ready.") } + return + } + + val targetSession = runtimeSessionId() + if (targetSession.isNullOrBlank()) { + _uiState.update { it.copy(errorMessage = "No active session.") } + return + } + + if (!ConnectorRepository.isValidSlug(slug)) { + _uiState.update { it.copy(errorMessage = "Invalid connector slug.") } + return + } + + val catalogItem = _uiState.value.items.firstOrNull { it.slug == slug } + if (catalogItem == null) { + _uiState.update { it.copy(errorMessage = "Connector '$slug' not found in catalog.") } + return + } + if (!catalogItem.isEnabled) { + _uiState.update { it.copy(errorMessage = "Connector '$slug' is disabled.") } + return + } + + if (_uiState.value.actionState is ConnectorActionState.Connecting) { + return + } + + // Cancel any prior in-flight read before starting mutation + refreshJob?.cancel() + refreshJob = null + pendingTrailingRefresh = false + + val gen = sessionGeneration + _uiState.update { + it.copy( + actionState = ConnectorActionState.Connecting(slug, reconnect), + errorMessage = null, + sessionId = targetSession, + generation = gen, + ) + } + + connectJob = + scope.launch { + var needsRefresh = false + try { + val result = + withContext(ioDispatcher) { + repository.connect(targetSession, listOf(slug), reconnect) + } + + // Verify generation, session equality, and visibility before applying + val activeSid = runtimeSessionId() + if (gen != sessionGeneration || + targetSession != activeSid || + !_uiState.value.isVisible + ) { + return@launch + } + + when (result) { + is ConnectorConnectResult.Success -> { + // Require exact slug match; never fallback to unrelated connector + val item = result.results.firstOrNull { it.connector == slug } + if (item == null) { + _uiState.update { + it.copy( + actionState = ConnectorActionState.Idle, + errorMessage = "Connector '$slug' not found in response.", + ) + } + return@launch + } + + when (item.status) { + ConnectorConnectStatus.ACTIVE -> { + _uiState.update { it.copy(actionState = ConnectorActionState.Idle) } + needsRefresh = true + } + + ConnectorConnectStatus.INITIATED -> { + val safeUrl = item.safeConnectUrl + if (safeUrl != null && ConnectorUrlValidator.isValidHttpsUrl(safeUrl)) { + val event = + ConnectBrowserEvent( + slug = slug, + url = safeUrl, + sessionId = targetSession, + generation = gen, + ) + browserLaunched = false + if (isForeground) { + _uiState.update { + it.copy( + actionState = ConnectorActionState.AwaitingAuthorization(slug), + browserLaunchEvent = event, + ) + } + } else { + // In background: hold transiently for foreground resume + pendingBrowserEvent = event + _uiState.update { + it.copy( + actionState = ConnectorActionState.AwaitingAuthorization(slug), + browserLaunchEvent = null, + ) + } + } + } else { + // Unsafe or absent URL is a retryable generic failure + _uiState.update { + it.copy( + actionState = ConnectorActionState.Idle, + errorMessage = "Invalid or unsafe authorization URL received.", + ) + } + } + } + + ConnectorConnectStatus.FAILED -> { + _uiState.update { + it.copy( + actionState = ConnectorActionState.Idle, + errorMessage = "Connector authorization failed. Please try again.", + ) + } + } + + else -> { + _uiState.update { + it.copy( + actionState = ConnectorActionState.Idle, + errorMessage = "Connector operation returned unexpected status.", + ) + } + } + } + } + + is ConnectorConnectResult.Error -> { + when (val err = result.error) { + is ConnectorError.UnsupportedBackend -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.UnsupportedBackend(err.message), + actionState = ConnectorActionState.Idle, + isOldBackend = true, + items = emptyList(), + errorMessage = err.message, + ) + } + } + + is ConnectorError.Unavailable -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.Unavailable(err.message), + actionState = ConnectorActionState.Idle, + items = emptyList(), + errorMessage = err.message, + ) + } + } + + is ConnectorError.UnsupportedRuntime -> { + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.UnsupportedRuntime(err.message), + actionState = ConnectorActionState.Idle, + items = emptyList(), + errorMessage = err.message, + ) + } + } + + is ConnectorError.NotOwner -> { + val msg = "Session is not owned by this transport. Please reconnect the session." + _uiState.update { + it.copy( + loadPhase = ConnectorsLoadPhase.NotOwner(msg), + actionState = ConnectorActionState.Idle, + errorMessage = msg, + ) + } + } + + else -> { + _uiState.update { + it.copy( + actionState = ConnectorActionState.Idle, + errorMessage = err.message, + ) + } + } + } + } + } + } catch (e: CancellationException) { + _uiState.update { + if (it.actionState is ConnectorActionState.Connecting) { + it.copy(actionState = ConnectorActionState.Idle) + } else { + it + } + } + throw e + } catch (t: Throwable) { + _uiState.update { + it.copy( + actionState = ConnectorActionState.Idle, + errorMessage = t.message ?: "Connector connection failed.", + ) + } + } finally { + connectJob = null + _uiState.update { + if (it.actionState is ConnectorActionState.Connecting) { + it.copy(actionState = ConnectorActionState.Idle) + } else { + it + } + } + val shouldRefresh = needsRefresh || pendingRefreshAfterMutation + if (shouldRefresh && + gen == sessionGeneration && + _uiState.value.isVisible + ) { + if (isForeground) { + pendingRefreshAfterMutation = false + refresh(force = true) + } else { + pendingRefreshAfterMutation = true + } + } else if (gen != sessionGeneration || !_uiState.value.isVisible) { + pendingRefreshAfterMutation = false + } + } + } + } + + /** + * Consume transient browser launch event (legacy no-arg API). + */ + fun consumeBrowserEvent() { + val currentSid = runtimeSessionId() + val event = _uiState.value.browserLaunchEvent ?: pendingBrowserEvent + if (event != null && + isForeground && + _uiState.value.isVisible && + event.generation == sessionGeneration && + event.sessionId == currentSid + ) { + browserLaunched = true + } + pendingBrowserEvent = null + _uiState.update { it.copy(browserLaunchEvent = null) } + } + + /** + * Consume transient browser launch event validating identity, session, generation, and foreground state. + */ + fun consumeBrowserEvent(eventId: Long): ConnectBrowserEvent? { + var consumed: ConnectBrowserEvent? = null + val currentSid = runtimeSessionId() + val pending = pendingBrowserEvent + if (pending != null && pending.eventId == eventId) { + if (isForeground && + _uiState.value.isVisible && + pending.generation == sessionGeneration && + pending.sessionId == currentSid + ) { + consumed = pending + pendingBrowserEvent = null + } else { + pendingBrowserEvent = null + } + } + _uiState.update { current -> + val event = current.browserLaunchEvent + if (event != null && event.eventId == eventId) { + if (isForeground && + current.isVisible && + event.generation == sessionGeneration && + event.sessionId == currentSid + ) { + consumed = event + current.copy(browserLaunchEvent = null) + } else { + // Invalid context: clear stale event and reject handoff + current.copy(browserLaunchEvent = null) + } + } else { + current + } + } + if (consumed != null) { + browserLaunched = true + } + return consumed + } + + /** + * Safely take transient browser launch event if valid. + */ + fun takeBrowserEvent(eventId: Long? = null): ConnectBrowserEvent? { + val targetId = + eventId + ?: _uiState.value.browserLaunchEvent?.eventId + ?: pendingBrowserEvent?.eventId + ?: return null + return consumeBrowserEvent(targetId) + } + + /** + * Report an error launching the external browser intent. + */ + fun launchError(message: String) { + pendingBrowserEvent = null + browserLaunched = false + _uiState.update { + it.copy( + browserLaunchEvent = null, + actionState = ConnectorActionState.Idle, + errorMessage = message, + ) + } + } + + fun onLaunchError(message: String) = launchError(message) + + /** + * Clear active error message. + */ + fun clearError() { + _uiState.update { it.copy(errorMessage = null) } + } + + /** + * Teardown ongoing coroutines and invalidate state generation. + */ + fun destroy() { + sessionGeneration++ + refreshJob?.cancel() + refreshJob = null + connectJob?.cancel() + connectJob = null + pendingRefreshAfterMutation = false + pendingTrailingRefresh = false + pendingBrowserEvent = null + browserLaunched = false + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt new file mode 100644 index 00000000..6aff6dab --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt @@ -0,0 +1,84 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesWsClient +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch + +/** + * Scoped ViewModel managing session connectors / integrations lifecycle (issue #1091). + * + * Hosts [ChatConnectorsDelegate] across configuration changes, wires [ActiveSessionHolder] + * and server/profile context transitions to invalidate context on changes, and forwards + * lifecycle and browser launch events safely. + */ +class ChatConnectorsViewModel( + delegate: ChatConnectorsDelegate? = null, +) : ViewModel() { + private val delegateInstance: ChatConnectorsDelegate = + delegate ?: ChatConnectorsDelegate( + scope = viewModelScope, + runtimeSessionId = { ActiveSessionHolder.activeSessionId.value }, + ) + + val uiState: StateFlow = delegateInstance.uiState + + init { + var lastContext: Triple? = null + + viewModelScope.launch { + combine( + ActiveSessionHolder.activeSessionId, + AuthManager.activeProfileId, + AuthManager.baseUrlFlow, + ) { sessionId, profileId, baseUrl -> + Triple(sessionId, profileId, baseUrl) + }.collect { current -> + if (lastContext == null || current != lastContext) { + lastContext = current + delegateInstance.onActiveSessionChanged(current.first) + } + } + } + + viewModelScope.launch { + HermesWsClient.connectionStatus + .filter { it == ConnectionStatus.CONNECTED } + .collect { + delegateInstance.onTransportReconnected() + } + } + } + + fun show() = delegateInstance.show() + + fun hide() = delegateInstance.hide() + + fun onResume() = delegateInstance.onResume() + + fun onPause() = delegateInstance.onPause() + + fun refresh(force: Boolean = false) = delegateInstance.refresh(force) + + fun connect( + slug: String, + reconnect: Boolean = false, + ) = delegateInstance.connect(slug, reconnect) + + fun takeBrowserEvent(eventId: Long? = null): ConnectBrowserEvent? = delegateInstance.takeBrowserEvent(eventId) + + fun launchError(message: String) = delegateInstance.launchError(message) + + fun clearError() = delegateInstance.clearError() + + override fun onCleared() { + super.onCleared() + delegateInstance.destroy() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 221b0cac..db8bfc30 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -1,6 +1,8 @@ package com.m57.hermescontrol.ui.chat import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.ContextWrapper import android.content.Intent import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult @@ -30,9 +32,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -44,6 +50,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -58,11 +65,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle @@ -90,6 +99,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReloginDialog import com.m57.hermescontrol.ui.chat.components.SearchBarRow +import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet import com.m57.hermescontrol.ui.chat.components.TaskProgressChip @@ -104,6 +114,7 @@ import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.model.components.ModelPickerDialog +import com.m57.hermescontrol.util.ConnectorUrlValidator import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged @@ -143,10 +154,60 @@ fun ChatScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle() + val connectorsViewModel: ChatConnectorsViewModel = viewModel() + val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle() // Snapshot-backed search state — read directly so only the scopes that // read its fields recompose on search changes (bar, matched bubbles). val searchState = viewModel.searchState val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } + var browserAuthDeparted by rememberSaveable { mutableStateOf(false) } + + DisposableEffect(lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> { + if (browserAuthInFlight) { + browserAuthDeparted = true + } + connectorsViewModel.onPause() + } + + Lifecycle.Event.ON_RESUME -> { + connectorsViewModel.onResume() + if (browserAuthInFlight && browserAuthDeparted) { + browserAuthInFlight = false + browserAuthDeparted = false + ExternalActivityLifecycleGuard.externalActivityReturned() + } + } + + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + val activity = + generateSequence(context) { (it as? ContextWrapper)?.baseContext } + .filterIsInstance() + .firstOrNull() + val isChangingConfigs = activity?.isChangingConfigurations == true + if (!isChangingConfigs) { + if (browserAuthInFlight && browserAuthDeparted) { + browserAuthInFlight = false + browserAuthDeparted = false + ExternalActivityLifecycleGuard.externalActivityReturned() + } + connectorsViewModel.onPause() + connectorsViewModel.hide() + } + } + } + + val browserEvent = connectorsState.browserLaunchEvent val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy) val scrollScope = rememberCoroutineScope() val scrollController = rememberChatScrollController(listState, scrollScope) @@ -299,7 +360,6 @@ fun ChatScreen( var viewingImage by rememberSaveable { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } val isDark = isSystemInDarkTheme() - val context = LocalContext.current val launchExternalActivity: (() -> Unit) -> Unit = { launch -> ExternalActivityLifecycleGuard.launchExternalActivity( acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, @@ -310,6 +370,45 @@ fun ChatScreen( ) } + val invalidResponseError = stringResource(R.string.session_integrations_err_invalid_response) + val browserLaunchError = stringResource(R.string.session_integrations_err_browser_launch) + + LaunchedEffect(browserEvent) { + if (browserEvent != null) { + val eventId = browserEvent.eventId + val taken = connectorsViewModel.takeBrowserEvent(eventId) + if (taken != null) { + if (!ConnectorUrlValidator.isValidHttpsUrl(taken.url)) { + connectorsViewModel.launchError(invalidResponseError) + return@LaunchedEffect + } + try { + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse(taken.url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + launchExternalActivity { + browserAuthDeparted = false + browserAuthInFlight = true + context.startActivity(intent) + } + } catch (_: ActivityNotFoundException) { + browserAuthInFlight = false + browserAuthDeparted = false + connectorsViewModel.launchError(browserLaunchError) + } catch (_: SecurityException) { + browserAuthInFlight = false + browserAuthDeparted = false + connectorsViewModel.launchError(browserLaunchError) + } catch (_: Exception) { + browserAuthInFlight = false + browserAuthDeparted = false + connectorsViewModel.launchError(browserLaunchError) + } + } + } + } + val mediaLaunchers = rememberChatMediaLaunchers( inputFieldValue = inputFieldValue, @@ -428,6 +527,39 @@ fun ChatScreen( contentDescription = stringResource(R.string.content_desc_new_chat), ) } + + // Session actions overflow menu (issue #1091) + var showSessionMenu by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { showSessionMenu = true }, + modifier = Modifier.testTag("chat_session_menu_button"), + ) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(R.string.session_integrations_menu_item), + ) + } + DropdownMenu( + expanded = showSessionMenu, + onDismissRequest = { showSessionMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.session_integrations_menu_item)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Extension, + contentDescription = null, + ) + }, + onClick = { + showSessionMenu = false + connectorsViewModel.show() + }, + modifier = Modifier.testTag("chat_menu_session_integrations"), + ) + } + } }, ) { _ -> Column( @@ -763,5 +895,14 @@ fun ChatScreen( onDismiss = { viewingImage = null }, ) } + + SessionIntegrationsSheet( + uiState = connectorsState, + sessionId = connectorsState.sessionId, + onDismiss = connectorsViewModel::hide, + onRefresh = { connectorsViewModel.refresh(force = true) }, + onConnect = { slug, reconnect -> connectorsViewModel.connect(slug, reconnect) }, + onClearError = connectorsViewModel::clearError, + ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheet.kt new file mode 100644 index 00000000..a45daba8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheet.kt @@ -0,0 +1,752 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.HourglassTop +import androidx.compose.material.icons.filled.LinkOff +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorStatus +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.chat.ChatConnectorsUiState +import com.m57.hermescontrol.ui.chat.ConnectorActionState +import com.m57.hermescontrol.ui.chat.ConnectorUiItem +import com.m57.hermescontrol.ui.chat.ConnectorsLoadPhase + +/** + * Visual elements for a connector status badge. + */ +data class ConnectorStatusVisuals( + val icon: ImageVector, + val color: Color, + val label: String, +) + +@Composable +fun resolveConnectorStatusVisuals( + status: ConnectorStatus, + isConnected: Boolean, +): ConnectorStatusVisuals { + val statusColors = LocalHermesStatusColors.current + return when { + isConnected || status == ConnectorStatus.CONNECTED -> { + ConnectorStatusVisuals( + icon = Icons.Filled.CheckCircle, + color = statusColors.success, + label = stringResource(R.string.session_integrations_status_connected), + ) + } + + status == ConnectorStatus.INITIATED -> { + ConnectorStatusVisuals( + icon = Icons.Filled.HourglassTop, + color = statusColors.info, + label = stringResource(R.string.session_integrations_awaiting), + ) + } + + status == ConnectorStatus.EXPIRED -> { + ConnectorStatusVisuals( + icon = Icons.Filled.Warning, + color = statusColors.warning, + label = stringResource(R.string.session_integrations_status_expired), + ) + } + + status == ConnectorStatus.REVOKED -> { + ConnectorStatusVisuals( + icon = Icons.Filled.Warning, + color = statusColors.warning, + label = stringResource(R.string.session_integrations_status_revoked), + ) + } + + status == ConnectorStatus.FAILED -> { + ConnectorStatusVisuals( + icon = Icons.Filled.ErrorOutline, + color = statusColors.error, + label = stringResource(R.string.session_integrations_status_failed), + ) + } + + status == ConnectorStatus.DISCONNECTED -> { + ConnectorStatusVisuals( + icon = Icons.Filled.LinkOff, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + label = stringResource(R.string.session_integrations_status_disconnected), + ) + } + + else -> { + ConnectorStatusVisuals( + icon = Icons.AutoMirrored.Filled.HelpOutline, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + label = stringResource(R.string.session_integrations_status_unknown), + ) + } + } +} + +@Composable +fun mapLoadPhaseErrorMessage(loadPhase: ConnectorsLoadPhase): String? = + when (loadPhase) { + is ConnectorsLoadPhase.Error -> { + mapConnectorErrorMessage(loadPhase.error) + } + + is ConnectorsLoadPhase.Unavailable -> { + stringResource(R.string.session_integrations_err_unavailable) + } + + is ConnectorsLoadPhase.UnsupportedRuntime -> { + stringResource( + R.string.session_integrations_err_unsupported_runtime, + ) + } + + is ConnectorsLoadPhase.UnsupportedBackend -> { + stringResource( + R.string.session_integrations_err_unsupported_backend, + ) + } + + is ConnectorsLoadPhase.NotOwner -> { + stringResource(R.string.session_integrations_err_not_owner) + } + + else -> { + null + } + } + +@Composable +fun mapConnectorErrorMessage(error: ConnectorError): String = + when (error) { + is ConnectorError.InvalidParams -> { + stringResource(R.string.session_integrations_err_invalid_params) + } + + is ConnectorError.NotOwner -> { + stringResource(R.string.session_integrations_err_not_owner) + } + + is ConnectorError.Unavailable -> { + stringResource(R.string.session_integrations_err_unavailable) + } + + is ConnectorError.UnsupportedRuntime -> { + stringResource(R.string.session_integrations_err_unsupported_runtime) + } + + is ConnectorError.UnsupportedBackend -> { + stringResource(R.string.session_integrations_err_unsupported_backend) + } + + is ConnectorError.RequestFailed -> { + stringResource(R.string.session_integrations_err_request_failed) + } + + is ConnectorError.InvalidResponse, is ConnectorError.MalformedEnvelope -> { + stringResource(R.string.session_integrations_err_invalid_response) + } + + is ConnectorError.NetworkError -> { + stringResource(R.string.session_integrations_err_network) + } + + is ConnectorError.Other -> { + stringResource(R.string.session_integrations_err_generic) + } + } + +@Composable +fun mapActionErrorMessage(rawError: String?): String = + when { + rawError.isNullOrBlank() -> { + stringResource(R.string.session_integrations_err_generic) + } + + rawError.contains("not owned", ignoreCase = true) -> { + stringResource(R.string.session_integrations_err_not_owner) + } + + rawError.contains( + "No active session", + ignoreCase = true, + ) -> { + stringResource(R.string.session_integrations_no_session) + } + + rawError.contains("Invalid connector slug", ignoreCase = true) || + rawError.contains("Invalid params", ignoreCase = true) -> { + stringResource(R.string.session_integrations_err_invalid_params) + } + + rawError.contains("authorization URL", ignoreCase = true) || + rawError.contains("unexpected status", ignoreCase = true) -> { + stringResource(R.string.session_integrations_err_invalid_response) + } + + rawError.contains("authorization failed", ignoreCase = true) -> { + stringResource(R.string.session_integrations_err_request_failed) + } + + rawError.contains("browser", ignoreCase = true) -> { + stringResource(R.string.session_integrations_err_browser_launch) + } + + else -> { + stringResource(R.string.session_integrations_err_generic) + } + } + +/** + * Native bottom sheet for session integrations / connectors (issue #1091). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionIntegrationsSheet( + uiState: ChatConnectorsUiState, + sessionId: String? = uiState.sessionId, + onDismiss: () -> Unit, + onRefresh: () -> Unit, + onConnect: (slug: String, reconnect: Boolean) -> Unit, + onClearError: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), +) { + if (!uiState.isVisible) return + + val effectiveSessionId = sessionId ?: uiState.sessionId + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier.testTag("session_integrations_sheet"), + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + SessionIntegrationsContent( + uiState = uiState, + sessionId = effectiveSessionId, + onDismiss = onDismiss, + onRefresh = onRefresh, + onConnect = onConnect, + onClearError = onClearError, + ) + } +} + +/** + * Stateless content composable for testing and decoupled layout. + */ +@Composable +fun SessionIntegrationsContent( + uiState: ChatConnectorsUiState, + sessionId: String? = uiState.sessionId, + onDismiss: () -> Unit, + onRefresh: () -> Unit, + onConnect: (slug: String, reconnect: Boolean) -> Unit, + onClearError: () -> Unit, + modifier: Modifier = Modifier, +) { + val statusColors = LocalHermesStatusColors.current + val effectiveSessionId = sessionId ?: uiState.sessionId + val isSessionBlank = effectiveSessionId.isNullOrBlank() + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + ) { + // Top Header + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.session_integrations_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.testTag("session_integrations_title"), + ) + Spacer(modifier = Modifier.height(2.dp)) + val subtitleText = + if (!isSessionBlank) { + val displayId = + if (effectiveSessionId.length > + 12 + ) { + "${effectiveSessionId.take(12)}…" + } else { + effectiveSessionId + } + stringResource(R.string.session_integrations_subtitle, displayId) + } else { + stringResource(R.string.session_integrations_no_session) + } + Text( + text = subtitleText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("session_integrations_subtitle"), + ) + } + + IconButton( + onClick = onRefresh, + enabled = !uiState.isLoading && !isSessionBlank, + modifier = Modifier.testTag("session_integrations_refresh_button"), + ) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringResource(R.string.session_integrations_refresh), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + IconButton( + onClick = onDismiss, + modifier = Modifier.testTag("session_integrations_close_button"), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.session_integrations_close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Action Error Banner + if (uiState.errorMessage != null) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .testTag("session_integrations_action_error_banner"), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = mapActionErrorMessage(uiState.errorMessage), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = onClearError, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.session_integrations_close), + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(24.dp), + ) + } + } + } + } + + // Awaiting Authorization Banner (manual Refresh status affordance) + val awaitingSlug = + (uiState.actionState as? ConnectorActionState.AwaitingAuthorization)?.slug + if (awaitingSlug != null) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .testTag("session_integrations_awaiting_banner"), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.OpenInBrowser, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.session_integrations_awaiting), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.session_integrations_awaiting_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.height(8.dp)) + FilledTonalButton( + onClick = onRefresh, + modifier = + Modifier + .align(Alignment.End) + .testTag("session_integrations_refresh_status_button"), + ) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.session_integrations_refresh_status), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + + // Content States + when { + isSessionBlank || (uiState.loadPhase is ConnectorsLoadPhase.Initial && uiState.items.isEmpty()) -> { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 180.dp) + .testTag("session_integrations_no_session_state"), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(24.dp), + ) { + Icon( + imageVector = Icons.Filled.LinkOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(40.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.session_integrations_no_session), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + uiState.loadPhase is ConnectorsLoadPhase.Loading -> { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 180.dp) + .testTag("session_integrations_loading"), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(24.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.session_integrations_connecting), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + uiState.loadPhase !is ConnectorsLoadPhase.Loading && + uiState.loadPhase !is ConnectorsLoadPhase.Refreshing && + uiState.loadPhase !is ConnectorsLoadPhase.Loaded && + uiState.items.isEmpty() -> { + // Non-loaded error / unavailable / unsupported / not owner terminal states + val errorMessage = mapLoadPhaseErrorMessage(uiState.loadPhase) ?: "" + val canRetry = (uiState.loadPhase as? ConnectorsLoadPhase.Error)?.canRetry ?: false + + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 180.dp) + .testTag("session_integrations_error"), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(24.dp), + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = statusColors.warning, + modifier = Modifier.size(36.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.testTag("session_integrations_error_text"), + ) + if (canRetry) { + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton( + onClick = onRefresh, + modifier = Modifier.testTag("session_integrations_retry_button"), + ) { + Text(stringResource(R.string.session_integrations_retry)) + } + } + } + } + } + + uiState.items.isEmpty() -> { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 180.dp) + .testTag("session_integrations_empty"), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(24.dp), + ) { + Icon( + imageVector = Icons.Filled.Extension, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(40.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.session_integrations_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + else -> { + // List of items + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .testTag("session_integrations_list"), + ) { + if (uiState.loadPhase is ConnectorsLoadPhase.Refreshing) { + item { + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .testTag("session_integrations_refreshing_indicator"), + ) + } + } + + items( + items = uiState.items, + key = { it.slug }, + ) { item -> + ConnectorRow( + item = item, + actionState = uiState.actionState, + isLoading = uiState.isLoading, + onConnect = onConnect, + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + } + } + } + } +} + +@Composable +fun ConnectorRow( + item: ConnectorUiItem, + actionState: ConnectorActionState, + isLoading: Boolean, + onConnect: (slug: String, reconnect: Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val visuals = resolveConnectorStatusVisuals(item.status, item.isConnected) + val isCurrentConnecting = + actionState is ConnectorActionState.Connecting && actionState.slug == item.slug + val isAnyActionPending = + actionState is ConnectorActionState.Connecting || actionState is ConnectorActionState.AwaitingAuthorization + val needsReconnect = + item.isConnected || + item.status == ConnectorStatus.EXPIRED || + item.status == ConnectorStatus.REVOKED + + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + .testTag("session_integrations_item_${item.slug}"), + verticalAlignment = Alignment.CenterVertically, + ) { + // Accessible Status Indicator Icon + Box( + modifier = + Modifier + .size(28.dp) + .clip(CircleShape) + .background(visuals.color.copy(alpha = 0.15f)) + .testTag("session_integrations_status_${item.slug}"), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = visuals.icon, + contentDescription = visuals.label, + tint = visuals.color, + modifier = Modifier.size(16.dp), + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = visuals.label, + style = MaterialTheme.typography.bodySmall, + color = visuals.color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + if (isCurrentConnecting) { + CircularProgressIndicator( + modifier = + Modifier + .size(20.dp) + .testTag("session_integrations_spinner_${item.slug}"), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + val buttonText = + if (needsReconnect) { + stringResource(R.string.session_integrations_reconnect) + } else { + stringResource(R.string.session_integrations_connect) + } + + OutlinedButton( + onClick = { onConnect(item.slug, needsReconnect) }, + enabled = item.isEnabled && !isAnyActionPending && !isLoading, + modifier = Modifier.testTag("session_integrations_action_${item.slug}"), + ) { + Text( + text = buttonText, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/util/ConnectorUrlValidator.kt b/app/src/main/java/com/m57/hermescontrol/util/ConnectorUrlValidator.kt new file mode 100644 index 00000000..9497a91f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/util/ConnectorUrlValidator.kt @@ -0,0 +1,60 @@ +package com.m57.hermescontrol.util + +import java.net.URI + +/** + * Validates external connector authorization URLs. + * + * Enforces strict security constraints: + * - Must use HTTPS scheme (case-insensitive). + * - Must have a non-empty, valid host without spaces, backslashes, or control characters. + * - Must NOT contain user-info credentials (e.g. `https://user:pass@domain`). + * - Must NOT contain backslashes anywhere in the URI (guard against SSRF/open-redirect bypasses). + * - Port (if specified) must be valid (1..65535). + */ +object ConnectorUrlValidator { + private val HOST_PATTERN = Regex("""^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$""") + + fun isValidHttpsUrl(urlString: String?): Boolean { + if (urlString.isNullOrBlank()) return false + + // Disallow backslashes, whitespace, and control characters anywhere in the raw URL + for (ch in urlString) { + if (ch == '\\' || ch.isWhitespace() || ch.isISOControl()) { + return false + } + } + + return try { + val uri = URI(urlString) + val scheme = uri.scheme ?: return false + if (!scheme.equals("https", ignoreCase = true)) { + return false + } + + val host = uri.host + if (host.isNullOrBlank()) { + return false + } + + // Userinfo is strictly prohibited (e.g. credentials embedded in URL) + if (uri.rawUserInfo != null) { + return false + } + + // Check host structure (must match domain / alphanumeric pattern or valid IP) + if (!HOST_PATTERN.matches(host)) { + return false + } + + // Verify port if explicitly provided + if (uri.port != -1 && (uri.port < 1 || uri.port > 65535)) { + return false + } + + true + } catch (_: Exception) { + false + } + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 66e586fc..8e05c1b1 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1198,4 +1198,36 @@ الإعدادات تم إيقاف النقاش إيقاف التوليد + + + التكاملات + الجلسة النشطة: %1$s + لا توجد جلسة نشطة + لا توجد تكاملات متاحة لهذه الجلسة + تحديث التكاملات + ربط + إعادة الربط + جاري الاتصال… + بانتظار التفويض + أكمل تسجيل الدخول في المتصفح، ثم ارجع إلى هنا. + تحديث الحالة + متصل + غير متصل + منتهي الصلاحية + تم إلغاء الوصول + فشل + غير معروف + معلمات التكامل غير صالحة + الجلسة غير مملوكة بواسطة مسار النقل هذا. يرجى إعادة الاتصال بالجلسة. + التكاملات غير متوفرة في هذه الجلسة + يجب إدارة التكاملات على جهاز مضيف الجلسة + التكاملات غير مدعومة من هذا الخادم + فشل طلب التكامل. يرجى المحاولة مرة أخرى. + استجابة غير صالحة من خدمة التكامل + تعذر الاتصال بالبوابة + تعذر فتح المتصفح للتفويض + فشلت عملية التكامل + إعادة المحاولة + إغلاق + تكاملات الجلسة \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 074382d3..39b4ad51 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -888,4 +888,36 @@ 빠름 빠른 모드 이 모델에서는 빠른 모드를 사용할 수 없습니다 + + + 연동 + 활성 세션: %1$s + 활성 세션 없음 + 이 세션에서 사용할 수 있는 연동이 없습니다 + 연동 새로고침 + 연결 + 다시 연결 + 연결 중… + 인증 대기 중 + 브라우저에서 로그인을 완료한 후 다시 돌아오세요. + 상태 새로고침 + 연결됨 + 연결 해제됨 + 만료됨 + 취소됨 + 실패 + 알 수 없음 + 잘못된 커넥터 매개변수 + 이 전송 계층이 세션을 소유하고 있지 않습니다. 세션을 다시 연결하세요. + 이 세션에서는 연동을 사용할 수 없습니다 + 연동은 세션 호스트에서 관리해야 합니다 + 백엔드에서 연동 서비스를 지원하지 않습니다 + 커넥터 요청에 실패했습니다. 다시 시도해 주세요. + 커넥터 서비스에서 잘못된 응답을 반환했습니다 + 게이트웨이와의 통신에 실패했습니다 + 인증을 위한 브라우저를 열 수 없습니다 + 커넥터 작업에 실패했습니다 + 다시 시도 + 닫기 + 세션 연동 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 4f941cc8..a6c25201 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1175,4 +1175,36 @@ 仍要保存 调整限制 设置 + + + 集成 + 当前会话:%1$s + 无活动会话 + 此会话暂无可用集成 + 刷新集成 + 连接 + 重新连接 + 正在连接… + 等待授权 + 请在浏览器中完成登录,然后返回此处。 + 刷新状态 + 已连接 + 未连接 + 已过期 + 已撤销 + 失败 + 未知 + 连接器参数无效 + 此传输通道未拥有该会话。请重新连接会话。 + 此会话中不可使用集成 + 集成必须在会话所在的主机上进行管理 + 后端不支持集成服务 + 连接器请求失败,请重试。 + 连接器服务返回无效响应 + 与网关通信失败 + 无法打开浏览器以进行授权 + 连接器操作失败 + 重试 + 关闭 + 会话集成 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54c24310..21d1b61d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1387,4 +1387,36 @@ Verification Code Submit Skip + + + Integrations + Active Session: %1$s + No active session + No integrations available for this session + Refresh integrations + Connect + Reconnect + Connecting… + Awaiting authorization + Complete sign-in in your browser, then return here. + Refresh status + Connected + Disconnected + Expired + Revoked + Failed + Unknown + Invalid connector parameters + Session is not owned by this transport. Please reconnect the session. + Integrations are not available in this session + Integrations must be managed on the session compute host + Integrations are not supported by this backend + Connector request failed. Please try again. + Invalid response from connector service + Failed to communicate with gateway + Could not open browser for authorization + Connector operation failed + Retry + Close + Session Integrations diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorParserTest.kt new file mode 100644 index 00000000..b8108c2e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorParserTest.kt @@ -0,0 +1,742 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ConnectorConnectItem +import com.m57.hermescontrol.data.model.ConnectorConnectResult +import com.m57.hermescontrol.data.model.ConnectorConnectStatus +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorItem +import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.model.ConnectorStatus +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectorParserTest { + @Test + fun `parseListResult parses standard valid envelope`() { + val payload = + mapOf( + "available" to true, + "connectors" to + listOf( + mapOf( + "connector" to "linear", + "name" to "Linear", + "connected" to true, + "enabled" to true, + "connectionStatus" to "active", + ), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertTrue(success.available) + assertEquals(1, success.connectors.size) + val item = success.connectors[0] + assertEquals("linear", item.connector) + assertEquals("Linear", item.name) + assertTrue(item.connected) + assertTrue(item.enabled) + assertEquals(ConnectorStatus.CONNECTED, item.status) + assertTrue(item.isConnected) + } + + @Test + fun `parseListResult handles unavailable state`() { + val payload = + mapOf( + "available" to false, + "connectors" to emptyList>(), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertFalse(success.available) + assertTrue(success.connectors.isEmpty()) + } + + @Test + fun `parseListResult handles empty connectors list`() { + val payload = + mapOf( + "available" to true, + "connectors" to emptyList>(), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertTrue(success.available) + assertTrue(success.connectors.isEmpty()) + } + + @Test + fun `parseListResult deliberate boolean connected precedence over stale backend statuses`() { + // When connected is true, status is ALWAYS CONNECTED even if backend reports expired or revoked + val payloadExpired = + mapOf( + "connectors" to + listOf( + mapOf("connector" to "c1", "connected" to true, "connectionStatus" to "expired"), + mapOf("connector" to "c2", "connected" to true, "connectionStatus" to "revoked"), + ), + ) + val resultExpired = ConnectorParser.parseListResult(payloadExpired) as ConnectorListResult.Success + assertEquals(ConnectorStatus.CONNECTED, resultExpired.connectors[0].status) + assertEquals(ConnectorStatus.CONNECTED, resultExpired.connectors[1].status) + + // When connected is false, status reflects expired or revoked, and stale "active" resolves to DISCONNECTED + val payloadFalse = + mapOf( + "connectors" to + listOf( + mapOf("connector" to "c3", "connected" to false, "connectionStatus" to "expired"), + mapOf("connector" to "c4", "connected" to false, "connectionStatus" to "revoked"), + mapOf("connector" to "c5", "connected" to false, "connectionStatus" to "active"), + mapOf("connector" to "c6", "connected" to false, "connectionStatus" to "initiated"), + mapOf("connector" to "c7", "connected" to false, "connectionStatus" to "error"), + ), + ) + val resultFalse = ConnectorParser.parseListResult(payloadFalse) as ConnectorListResult.Success + assertEquals(ConnectorStatus.EXPIRED, resultFalse.connectors[0].status) + assertEquals(ConnectorStatus.REVOKED, resultFalse.connectors[1].status) + assertEquals(ConnectorStatus.DISCONNECTED, resultFalse.connectors[2].status) + assertEquals(ConnectorStatus.INITIATED, resultFalse.connectors[3].status) + assertEquals(ConnectorStatus.FAILED, resultFalse.connectors[4].status) + } + + @Test + fun `parseListResult supports legacy aliases`() { + val payload = + mapOf( + "connectors" to + listOf( + mapOf( + "slug" to "github", + "displayName" to "GitHub App", + "status" to "expired", + "auth_url" to "https://github.com/login/oauth/authorize", + ), + mapOf( + "id" to "jira", + "title" to "Jira Cloud", + "connection_status" to "revoked", + "connectUrl" to "https://auth.atlassian.com/oauth", + ), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertEquals(2, success.connectors.size) + + val c1 = success.connectors[0] + assertEquals("github", c1.connector) + assertEquals("GitHub App", c1.name) + assertEquals(ConnectorStatus.EXPIRED, c1.status) + assertEquals("https://github.com/login/oauth/authorize", c1.safeConnectUrl) + + val c2 = success.connectors[1] + assertEquals("jira", c2.connector) + assertEquals("Jira Cloud", c2.name) + assertEquals(ConnectorStatus.REVOKED, c2.status) + assertEquals("https://auth.atlassian.com/oauth", c2.safeConnectUrl) + } + + @Test + fun `parseListResult safely handles wrong optional field types without crash`() { + val payload = + mapOf( + "available" to "true", + "connectors" to + listOf( + mapOf( + "connector" to "slack", + "name" to 12345, // Number instead of string + "connected" to "true", // String instead of boolean + "enabled" to "false", // String instead of boolean + ), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + val item = success.connectors[0] + assertEquals("slack", item.connector) + assertEquals("12345", item.name) + assertTrue(item.connected) + assertFalse(item.enabled) + } + + @Test + fun `parseListResult maps unknown status to UNKNOWN`() { + val payload = + mapOf( + "connectors" to + listOf( + mapOf( + "connector" to "notion", + "connected" to false, + "connectionStatus" to "pending_approval_future_state", + ), + ), + ) + + val result = ConnectorParser.parseListResult(payload) as ConnectorListResult.Success + assertEquals(ConnectorStatus.UNKNOWN, result.connectors[0].status) + } + + @Test + fun `parseListResult fails on malformed required envelopes without fabricating empty success`() { + // Missing "connectors" key + val missingConnectors = mapOf("available" to true) + val r1 = ConnectorParser.parseListResult(missingConnectors) + assertTrue(r1 is ConnectorListResult.Error) + assertTrue((r1 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope) + + // "connectors" is a string instead of array + val stringConnectors = mapOf("connectors" to "invalid") + val r2 = ConnectorParser.parseListResult(stringConnectors) + assertTrue(r2 is ConnectorListResult.Error) + assertTrue((r2 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope) + + // Raw array at root instead of object + val rawList = listOf("c1", "c2") + val r3 = ConnectorParser.parseListResult(rawList) + assertTrue(r3 is ConnectorListResult.Error) + assertTrue((r3 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope) + + // Primitive string at root + val r4 = ConnectorParser.parseListResult("some random string") + assertTrue(r4 is ConnectorListResult.Error) + assertTrue((r4 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope) + + // Null root + val r5 = ConnectorParser.parseListResult(null) + assertTrue(r5 is ConnectorListResult.Error) + assertTrue((r5 as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope) + } + + @Test + fun `parseConnectResult parses standard connect initiated response`() { + val payload = + mapOf( + "results" to + listOf( + mapOf( + "connector" to "linear", + "status" to "initiated", + "connect_url" to "https://linear.app/oauth/authorize?client_id=123", + "instruction" to "Arbitrary backend instruction that must not be exposed as UI text", + ), + ), + "summary" to + mapOf( + "total" to 1, + "active" to 0, + "initiated" to 1, + "failed" to 0, + ), + ) + + val result = ConnectorParser.parseConnectResult(payload) + assertTrue(result is ConnectorConnectResult.Success) + val success = result as ConnectorConnectResult.Success + assertEquals(1, success.results.size) + val item = success.results[0] + assertEquals("linear", item.connector) + assertEquals(ConnectorConnectStatus.INITIATED, item.status) + assertTrue(item.isConnectUrlValid) + assertEquals("https://linear.app/oauth/authorize?client_id=123", item.safeConnectUrl) + assertEquals(1, success.summary.total) + assertEquals(1, success.summary.initiated) + assertEquals(0, success.summary.active) + } + + @Test + fun `parseConnectResult parses connect already active response`() { + val payload = + mapOf( + "results" to + listOf( + mapOf( + "connector" to "linear", + "status" to "active", + ), + ), + "summary" to + mapOf( + "total" to 1, + "active" to 1, + "initiated" to 0, + "failed" to 0, + ), + ) + + val result = ConnectorParser.parseConnectResult(payload) + assertTrue(result is ConnectorConnectResult.Success) + val success = result as ConnectorConnectResult.Success + assertEquals(1, success.results.size) + val item = success.results[0] + assertEquals("linear", item.connector) + assertEquals(ConnectorConnectStatus.ACTIVE, item.status) + assertTrue(item.status.isActive) + assertNull(item.safeConnectUrl) + } + + @Test + fun `parseConnectResult supports legacy aliases and unknown status`() { + val payload = + mapOf( + "results" to + listOf( + mapOf( + "slug" to "github", + "connectionStatus" to "failed", + "auth_url" to "https://github.com/login", + ), + mapOf( + "connector" to "notion", + "status" to "custom_unknown_status", + ), + ), + "summary" to + mapOf( + "total" to "2", // String count coerced safely + "failed" to "1", + ), + ) + + val result = ConnectorParser.parseConnectResult(payload) as ConnectorConnectResult.Success + assertEquals(2, result.results.size) + assertEquals("github", result.results[0].connector) + assertEquals(ConnectorConnectStatus.FAILED, result.results[0].status) + assertEquals("https://github.com/login", result.results[0].safeConnectUrl) + + assertEquals("notion", result.results[1].connector) + assertEquals(ConnectorConnectStatus.UNKNOWN, result.results[1].status) + + assertEquals(2, result.summary.total) + assertEquals(1, result.summary.failed) + assertEquals(0, result.summary.active) + } + + @Test + fun `parseConnectResult rejects insecure or invalid auth URLs`() { + val payload = + mapOf( + "results" to + listOf( + mapOf( + "connector" to "insecure_http", + "status" to "initiated", + "connect_url" to "http://example.com/oauth", + ), + mapOf( + "connector" to "js_proto", + "status" to "initiated", + "connect_url" to "javascript:alert(1)", + ), + mapOf( + "connector" to "user_creds", + "status" to "initiated", + "connect_url" to "https://user:pass@evil.com/", + ), + ), + "summary" to + mapOf( + "total" to 3, + "initiated" to 3, + ), + ) + + val result = ConnectorParser.parseConnectResult(payload) as ConnectorConnectResult.Success + assertEquals(3, result.results.size) + for (item in result.results) { + assertFalse(item.isConnectUrlValid) + assertNull(item.safeConnectUrl) + } + } + + @Test + fun `parseConnectResult fails on malformed required envelope without fabricating success`() { + // Missing results + val r1 = ConnectorParser.parseConnectResult(mapOf("summary" to mapOf("total" to 0))) + assertTrue(r1 is ConnectorConnectResult.Error) + assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope) + + // Results is a string + val r2 = ConnectorParser.parseConnectResult(mapOf("results" to "not a list")) + assertTrue(r2 is ConnectorConnectResult.Error) + assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope) + + // Primitive string + val r3 = ConnectorParser.parseConnectResult("random text") + assertTrue(r3 is ConnectorConnectResult.Error) + } + + @Test + fun `toString redacts auth URLs on ConnectorItem and ConnectorConnectItem`() { + val sensitiveUrl = "https://example.com/oauth?client_secret=super_secret_token_12345" + val item = + ConnectorItem( + connector = "linear", + name = "Linear", + connectUrl = sensitiveUrl, + ) + assertFalse(item.toString().contains("super_secret_token_12345")) + assertTrue(item.toString().contains("[REDACTED]")) + + val connectItem = + ConnectorConnectItem( + connector = "linear", + status = ConnectorConnectStatus.INITIATED, + connectUrl = sensitiveUrl, + ) + assertFalse(connectItem.toString().contains("super_secret_token_12345")) + assertTrue(connectItem.toString().contains("[REDACTED]")) + } + + @Test + fun `mapRpcError correctly maps backend codes and reasons`() { + // 4000 INVALID_PARAMS + val e4000 = + ConnectorParser.mapRpcError( + code = 4000, + message = "session_id required", + data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_PARAMS"))), + ) + assertTrue(e4000 is ConnectorError.InvalidParams) + + // 4001 NOT_OWNER + val e4001 = + ConnectorParser.mapRpcError( + code = 4001, + message = "session not owned", + data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))), + ) + assertTrue(e4001 is ConnectorError.NotOwner) + + // 4031 CONNECTORS_UNAVAILABLE + val e4031 = + ConnectorParser.mapRpcError( + code = 4031, + message = "connectors unavailable", + data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))), + ) + assertTrue(e4031 is ConnectorError.Unavailable) + + // 5033 UNSUPPORTED_RUNTIME + val e5033 = + ConnectorParser.mapRpcError( + code = 5033, + message = "compute host required", + data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))), + ) + assertTrue(e5033 is ConnectorError.UnsupportedRuntime) + + // 5034 INVALID_CONNECTOR_RESPONSE + val e5034a = + ConnectorParser.mapRpcError( + code = 5034, + message = "invalid response", + data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))), + ) + assertTrue(e5034a is ConnectorError.InvalidResponse) + + // 5034 CONNECTOR_REQUEST_FAILED + val e5034b = + ConnectorParser.mapRpcError( + code = 5034, + message = "request failed", + data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTOR_REQUEST_FAILED"))), + ) + assertTrue(e5034b is ConnectorError.RequestFailed) + + // -32601 Method not found + val e32601 = + ConnectorParser.mapRpcError( + code = -32601, + message = "Method not found", + data = null, + ) + assertTrue(e32601 is ConnectorError.UnsupportedBackend) + + // Other code + val eOther = + ConnectorParser.mapRpcError( + code = 9999, + message = "Custom failure", + data = null, + ) + assertTrue(eOther is ConnectorError.Other) + assertEquals(9999, (eOther as ConnectorError.Other).code) + } + + @Test + fun `mapRpcError never leaks raw backend messages or secrets into typed error or toString`() { + val secretApiKey = "sk-live-secret-super-sensitive-api-key-987654321" + val secretDbPass = "postgres://user:super_secret_db_pass@10.0.0.1:5432/db" + + // Known error code with secret in message + val e4000 = + ConnectorParser.mapRpcError( + code = 4000, + message = "Invalid auth header: $secretApiKey", + data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_PARAMS"))), + ) + assertFalse("Error message must not leak secret", e4000.message.contains(secretApiKey)) + assertFalse("Error toString must not leak secret", e4000.toString().contains(secretApiKey)) + assertEquals("Invalid connector parameters.", e4000.message) + + // Unknown error code with secret in message + val eUnknown = + ConnectorParser.mapRpcError( + code = 8888, + message = "Internal connection failed to $secretDbPass", + data = null, + ) + assertFalse("Unknown error message must not leak secret", eUnknown.message.contains(secretDbPass)) + assertFalse("Unknown error toString must not leak secret", eUnknown.toString().contains(secretDbPass)) + assertEquals("Connector operation failed.", eUnknown.message) + } + + @Test + fun `parseConnectorItem strictly rejects non-string identifiers and invalid slug formats`() { + val payload = + mapOf( + "connectors" to + listOf( + mapOf("connector" to true, "name" to "Boolean Slug"), + mapOf("id" to 12345, "name" to "Number ID"), + mapOf("slug" to "INVALID UPPERCASE", "name" to "Uppercase"), + mapOf("connector" to "-leading-hyphen", "name" to "Leading Hyphen"), + mapOf("connector" to "slug with spaces", "name" to "Spaces"), + mapOf("connector" to "valid_slug-1", "name" to "Valid Item"), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertEquals(1, success.connectors.size) + assertEquals("valid_slug-1", success.connectors[0].connector) + assertEquals("Valid Item", success.connectors[0].name) + } + + @Test + fun `parseListResult deduplicates items by slug to prevent duplicate keys in lazy lists`() { + val payload = + mapOf( + "connectors" to + listOf( + mapOf("connector" to "github", "name" to "First GitHub"), + mapOf("connector" to "github", "name" to "Duplicate GitHub"), + mapOf("connector" to "slack", "name" to "Slack"), + mapOf("connector" to "slack", "name" to "Duplicate Slack"), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertEquals(2, success.connectors.size) + assertEquals("github", success.connectors[0].connector) + assertEquals("First GitHub", success.connectors[0].name) + assertEquals("slack", success.connectors[1].connector) + assertEquals("Slack", success.connectors[1].name) + } + + @Test + fun `parseListResult fails with MalformedEnvelope when nonempty connectors array contains only invalid items`() { + val payload = + mapOf( + "connectors" to + listOf( + "not an object", + 12345, + mapOf("connector" to true), + mapOf("slug" to "INVALID SLUG"), + mapOf("bogus" to "no identifier"), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Error) + assertTrue((result as ConnectorListResult.Error).error is ConnectorError.MalformedEnvelope) + } + + @Test + fun `parseListResult tolerates mixed junk items when valid items exist`() { + val payload = + mapOf( + "connectors" to + listOf( + "random junk string", + mapOf("connector" to "linear", "name" to "Linear"), + 12345, + mapOf("connector" to true), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertEquals(1, success.connectors.size) + assertEquals("linear", success.connectors[0].connector) + } + + @Test + fun `parseConnectResult fails when results is empty or all items invalid`() { + // Empty results array + val payloadEmpty = + mapOf( + "results" to emptyList>(), + "summary" to mapOf("total" to 0), + ) + val rEmpty = ConnectorParser.parseConnectResult(payloadEmpty) + assertTrue(rEmpty is ConnectorConnectResult.Error) + assertTrue((rEmpty as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse) + + // All invalid items + val payloadInvalid = + mapOf( + "results" to + listOf( + mapOf("connector" to true), + mapOf("id" to 12345), + mapOf("connector" to "INVALID UPPER"), + ), + "summary" to mapOf("total" to 3), + ) + val rInvalid = ConnectorParser.parseConnectResult(payloadInvalid) + assertTrue(rInvalid is ConnectorConnectResult.Error) + assertTrue((rInvalid as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse) + } + + @Test + fun `parseConnectResult fails when summary object is missing or non-object in standard envelope`() { + // Missing summary + val payloadMissingSummary = + mapOf( + "results" to + listOf( + mapOf("connector" to "linear", "status" to "active"), + ), + ) + val r1 = ConnectorParser.parseConnectResult(payloadMissingSummary) + assertTrue(r1 is ConnectorConnectResult.Error) + assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope) + + // Non-object summary (string) + val payloadStringSummary = + mapOf( + "results" to + listOf( + mapOf("connector" to "linear", "status" to "active"), + ), + "summary" to "invalid_string_summary", + ) + val r2 = ConnectorParser.parseConnectResult(payloadStringSummary) + assertTrue(r2 is ConnectorConnectResult.Error) + assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.MalformedEnvelope) + } + + @Test + fun `parseConnectResult tolerates purposeful legacy bare array form and derives summary`() { + val legacyPayload = + listOf( + mapOf("connector" to "linear", "status" to "active"), + mapOf("connector" to "github", "status" to "initiated", "connect_url" to "https://github.com/login"), + ) + + val result = ConnectorParser.parseConnectResult(legacyPayload) + assertTrue(result is ConnectorConnectResult.Success) + val success = result as ConnectorConnectResult.Success + assertEquals(2, success.results.size) + assertEquals("linear", success.results[0].connector) + assertEquals(ConnectorConnectStatus.ACTIVE, success.results[0].status) + assertEquals("github", success.results[1].connector) + assertEquals(ConnectorConnectStatus.INITIATED, success.results[1].status) + + // Summary should be synthesized + assertEquals(2, success.summary.total) + assertEquals(1, success.summary.active) + assertEquals(1, success.summary.initiated) + assertEquals(0, success.summary.failed) + } + + @Test + fun `legacy connected consistency - status authorized yields effective connected when boolean is missing`() { + val payload = + mapOf( + "connectors" to + listOf( + // Missing "connected" boolean, status "authorized" + mapOf("connector" to "c1", "connectionStatus" to "authorized"), + // Missing "connected" boolean, status "active" + mapOf("connector" to "c2", "connectionStatus" to "active"), + // Missing "connected" boolean, status "connected" + mapOf("connector" to "c3", "connectionStatus" to "connected"), + // Missing "connected" boolean, status "expired" + mapOf("connector" to "c4", "connectionStatus" to "expired"), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + assertEquals(4, success.connectors.size) + + val c1 = success.connectors[0] + assertEquals(ConnectorStatus.CONNECTED, c1.status) + assertTrue(c1.connected) + assertTrue(c1.isConnected) + + val c2 = success.connectors[1] + assertEquals(ConnectorStatus.CONNECTED, c2.status) + assertTrue(c2.connected) + assertTrue(c2.isConnected) + + val c3 = success.connectors[2] + assertEquals(ConnectorStatus.CONNECTED, c3.status) + assertTrue(c3.connected) + assertTrue(c3.isConnected) + + val c4 = success.connectors[3] + assertEquals(ConnectorStatus.EXPIRED, c4.status) + assertFalse(c4.connected) + assertFalse(c4.isConnected) + } + + @Test + fun `explicit connected false is authoritative and not overridden by status active or authorized`() { + val payload = + mapOf( + "connectors" to + listOf( + mapOf("connector" to "c1", "connected" to false, "connectionStatus" to "active"), + mapOf("connector" to "c2", "connected" to false, "connectionStatus" to "authorized"), + mapOf("connector" to "c3", "connected" to false, "connectionStatus" to "connected"), + ), + ) + + val result = ConnectorParser.parseListResult(payload) + assertTrue(result is ConnectorListResult.Success) + val success = result as ConnectorListResult.Success + for (item in success.connectors) { + assertFalse(item.connected) + assertFalse(item.isConnected) + assertEquals(ConnectorStatus.DISCONNECTED, item.status) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt new file mode 100644 index 00000000..bb95cc84 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt @@ -0,0 +1,376 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ConnectorConnectResult +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorListResult +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class ConnectorRepositoryTest { + @Test + fun `exact method constants match upstream gateway expectations`() { + assertEquals("connectors.list", WsMethods.CONNECTORS_LIST) + assertEquals("connectors.connect", WsMethods.CONNECTORS_CONNECT) + } + + @Test + fun `listConnectors sends exact parameters without extra profile injection`() = + runBlocking { + var recordedMethod: String? = null + var recordedParams: Map? = null + + val repo = + HermesConnectorRepository( + rpcRequest = { method, params -> + recordedMethod = method + recordedParams = params + mapOf("available" to true, "connectors" to emptyList>()) + }, + ) + + val result = repo.listConnectors("sess-abc-123") + assertTrue(result is ConnectorListResult.Success) + assertEquals(WsMethods.CONNECTORS_LIST, recordedMethod) + assertEquals(mapOf("session_id" to "sess-abc-123"), recordedParams) + // Verify no profile or singular slug mutations + assertFalse(recordedParams?.containsKey("profile") == true) + assertFalse(recordedParams?.containsKey("connector") == true) + } + + @Test + fun `connect sends exact parameters with connectors array and reconnect boolean`() = + runBlocking { + var recordedMethod: String? = null + var recordedParams: Map? = null + + val repo = + HermesConnectorRepository( + rpcRequest = { method, params -> + recordedMethod = method + recordedParams = params + mapOf( + "results" to + listOf( + mapOf("connector" to "linear", "status" to "initiated"), + ), + "summary" to mapOf("total" to 1, "initiated" to 1), + ) + }, + ) + + val result = + repo.connect( + sessionId = "sess-456", + connectors = listOf("linear", "github_app-1"), + reconnect = true, + ) + + assertTrue(result is ConnectorConnectResult.Success) + assertEquals(WsMethods.CONNECTORS_CONNECT, recordedMethod) + assertEquals("sess-456", recordedParams?.get("session_id")) + assertEquals(listOf("linear", "github_app-1"), recordedParams?.get("connectors")) + assertEquals(true, recordedParams?.get("reconnect")) + + // Verify no singular slug key mutation + assertFalse(recordedParams?.containsKey("connector") == true) + } + + @Test + fun `connect preserves reconnect false default`() = + runBlocking { + var recordedParams: Map? = null + + val repo = + HermesConnectorRepository( + rpcRequest = { _, params -> + recordedParams = params + mapOf( + "results" to + listOf( + mapOf("connector" to "slack", "status" to "active"), + ), + "summary" to mapOf("total" to 1, "active" to 1), + ) + }, + ) + + repo.connect(sessionId = "sess-789", connectors = listOf("slack")) + assertEquals(false, recordedParams?.get("reconnect")) + } + + @Test + fun `listConnectors validates non-blank session_id before network`() = + runBlocking { + var rpcCalled = false + val repo = + HermesConnectorRepository( + rpcRequest = { _, _ -> + rpcCalled = true + null + }, + ) + + val r1 = repo.listConnectors("") + val r2 = repo.listConnectors(" ") + + assertFalse(rpcCalled) + assertTrue(r1 is ConnectorListResult.Error) + assertTrue((r1 as ConnectorListResult.Error).error is ConnectorError.InvalidParams) + assertTrue(r2 is ConnectorListResult.Error) + assertTrue((r2 as ConnectorListResult.Error).error is ConnectorError.InvalidParams) + } + + @Test + fun `connect validates session_id and connectors list before network`() = + runBlocking { + var rpcCalled = false + val repo = + HermesConnectorRepository( + rpcRequest = { _, _ -> + rpcCalled = true + null + }, + ) + + // Blank session_id + val r1 = repo.connect(sessionId = "", connectors = listOf("linear")) + assertFalse(rpcCalled) + assertTrue(r1 is ConnectorConnectResult.Error) + assertTrue((r1 as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams) + + // Empty connectors + val r2 = repo.connect(sessionId = "sess-1", connectors = emptyList()) + assertFalse(rpcCalled) + assertTrue(r2 is ConnectorConnectResult.Error) + assertTrue((r2 as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams) + } + + @Test + fun `connect validates slug pattern matches backend expectations`() = + runBlocking { + var rpcCalled = false + val repo = + HermesConnectorRepository( + rpcRequest = { _, _ -> + rpcCalled = true + null + }, + ) + + // Invalid slugs: uppercase, starting with hyphen/underscore, containing spaces/symbols + val invalidSlugs = + listOf( + "Linear", // uppercase + "-linear", // leading hyphen + "_linear", // leading underscore + "linear app", // space + "linear/app", // slash + "linear@app", // symbol + "", // empty + ) + + for (invalidSlug in invalidSlugs) { + rpcCalled = false + val result = repo.connect(sessionId = "sess-1", connectors = listOf(invalidSlug)) + assertFalse("Expected RPC not to be called for invalid slug '$invalidSlug'", rpcCalled) + assertTrue("Expected InvalidParams error for '$invalidSlug'", result is ConnectorConnectResult.Error) + assertTrue((result as ConnectorConnectResult.Error).error is ConnectorError.InvalidParams) + } + + // Valid slugs + assertTrue(ConnectorRepository.isValidSlug("gmail")) + assertTrue(ConnectorRepository.isValidSlug("google_drive")) + assertTrue(ConnectorRepository.isValidSlug("jira-service-desk")) + assertTrue(ConnectorRepository.isValidSlug("0auth_provider")) + } + + @Test + fun `cancellation exception rethrows and is not swallowed`() = + runBlocking { + val repo = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw CancellationException("coroutine cancelled") + }, + ) + + try { + repo.listConnectors("sess-1") + fail("Expected CancellationException to be rethrown on listConnectors") + } catch (e: CancellationException) { + assertEquals("coroutine cancelled", e.message) + } + + try { + repo.connect("sess-1", listOf("linear")) + fail("Expected CancellationException to be rethrown on connect") + } catch (e: CancellationException) { + assertEquals("coroutine cancelled", e.message) + } + } + + @Test + fun `maps HermesRpcException to typed errors`() = + runBlocking { + // 4031 CONNECTORS_UNAVAILABLE + val repo4031 = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Connectors unavailable", + code = 4031, + data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))), + ) + }, + ) + val r4031 = repo4031.listConnectors("sess-1") + assertTrue(r4031 is ConnectorListResult.Error) + assertTrue((r4031 as ConnectorListResult.Error).error is ConnectorError.Unavailable) + + // 4001 NOT_OWNER + val repo4001 = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Session not owned", + code = 4001, + data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))), + ) + }, + ) + val r4001 = repo4001.connect("sess-1", listOf("linear")) + assertTrue(r4001 is ConnectorConnectResult.Error) + assertTrue((r4001 as ConnectorConnectResult.Error).error is ConnectorError.NotOwner) + + // 5033 UNSUPPORTED_RUNTIME + val repo5033 = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Compute host required", + code = 5033, + data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))), + ) + }, + ) + val r5033 = repo5033.connect("sess-1", listOf("linear")) + assertTrue(r5033 is ConnectorConnectResult.Error) + assertTrue((r5033 as ConnectorConnectResult.Error).error is ConnectorError.UnsupportedRuntime) + + // 5034 INVALID_CONNECTOR_RESPONSE + val repo5034 = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Invalid connector response", + code = 5034, + data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))), + ) + }, + ) + val r5034 = repo5034.connect("sess-1", listOf("linear")) + assertTrue(r5034 is ConnectorConnectResult.Error) + assertTrue((r5034 as ConnectorConnectResult.Error).error is ConnectorError.InvalidResponse) + + // -32601 Unsupported backend + val repo32601 = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Method not found", + code = -32601, + data = null, + ) + }, + ) + val r32601 = repo32601.listConnectors("sess-1") + assertTrue(r32601 is ConnectorListResult.Error) + assertTrue((r32601 as ConnectorListResult.Error).error is ConnectorError.UnsupportedBackend) + } + + @Test + fun `generic network exception maps to typed NetworkError`() = + runBlocking { + val repo = + HermesConnectorRepository( + rpcRequest = { _, _ -> + throw java.io.IOException("Connection reset by peer") + }, + ) + + val rList = repo.listConnectors("sess-1") + assertTrue(rList is ConnectorListResult.Error) + assertTrue((rList as ConnectorListResult.Error).error is ConnectorError.NetworkError) + + val rConnect = repo.connect("sess-1", listOf("linear")) + assertTrue(rConnect is ConnectorConnectResult.Error) + assertTrue((rConnect as ConnectorConnectResult.Error).error is ConnectorError.NetworkError) + } + + @Test + fun `default rpcRequest cancels underlying deferred on caller cancellation for listConnectors`() = + runBlocking { + mockkObject(HermesWsClient) + try { + val deferred = CompletableDeferred() + every { HermesWsClient.request(WsMethods.CONNECTORS_LIST, any(), any()) } returns deferred + every { HermesWsClient.request(WsMethods.CONNECTORS_LIST, any()) } returns deferred + + val repo = HermesConnectorRepository() + val job = + launch { + repo.listConnectors("sess-cancel-test") + } + + yield() + assertFalse("Deferred must not be cancelled while waiting", deferred.isCancelled) + + job.cancel() + job.join() + + assertTrue("Deferred must be cancelled when caller coroutine is cancelled", deferred.isCancelled) + } finally { + unmockkObject(HermesWsClient) + } + } + + @Test + fun `default rpcRequest cancels underlying deferred on caller cancellation for connect`() = + runBlocking { + mockkObject(HermesWsClient) + try { + val deferred = CompletableDeferred() + every { HermesWsClient.request(WsMethods.CONNECTORS_CONNECT, any(), any()) } returns deferred + every { HermesWsClient.request(WsMethods.CONNECTORS_CONNECT, any()) } returns deferred + + val repo = HermesConnectorRepository() + val job = + launch { + repo.connect("sess-cancel-test", listOf("linear")) + } + + yield() + assertFalse("Deferred must not be cancelled while waiting", deferred.isCancelled) + + job.cancel() + job.join() + + assertTrue("Deferred must be cancelled when caller coroutine is cancelled", deferred.isCancelled) + } finally { + unmockkObject(HermesWsClient) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt new file mode 100644 index 00000000..29bfaa84 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt @@ -0,0 +1,458 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +class HermesWsClientLoggingSecurityTest { + private val capturedDebugLogs = Collections.synchronizedList(mutableListOf()) + private val capturedInfoLogs = Collections.synchronizedList(mutableListOf()) + private val capturedWarnLogs = Collections.synchronizedList(mutableListOf()) + private val capturedErrorLogs = Collections.synchronizedList(mutableListOf()) + + @Before + fun setUp() { + capturedDebugLogs.clear() + capturedInfoLogs.clear() + capturedWarnLogs.clear() + capturedErrorLogs.clear() + + mockkStatic(Log::class) + every { Log.v(any(), any()) } answers { + capturedDebugLogs.add(secondArg()) + 0 + } + every { Log.d(any(), any()) } answers { + capturedDebugLogs.add(secondArg()) + 0 + } + every { Log.i(any(), any()) } answers { + capturedInfoLogs.add(secondArg()) + 0 + } + every { Log.w(any(), any()) } answers { + capturedWarnLogs.add(secondArg()) + 0 + } + every { Log.w(any(), any(), any()) } answers { + capturedWarnLogs.add(secondArg()) + 0 + } + every { Log.e(any(), any()) } answers { + capturedErrorLogs.add(secondArg()) + 0 + } + every { Log.e(any(), any(), any()) } answers { + capturedErrorLogs.add(secondArg()) + 0 + } + + mockkObject(AuthManager) + every { AuthManager.activeProfileId } returns MutableStateFlow(null) + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.wsUrl() } returns "ws://127.0.0.1:9119/" + + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + + val connectedField = HermesWsClient::class.java.getDeclaredField("connected") + connectedField.isAccessible = true + (connectedField.get(HermesWsClient) as AtomicBoolean).set(false) + + val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") + intentionalCloseField.isAccessible = true + (intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false) + + val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") + generationField.isAccessible = true + (generationField.get(HermesWsClient) as AtomicInteger).set(1) + + HermesWsClient.rejectAllPending() + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.setAppForeground(true) + } + + @After + fun tearDown() { + HermesWsClient.rejectAllPending() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + unmockkAll() + } + + private fun createWsListener(generation: Int? = null): WebSocketListener { + val targetGen = + generation ?: run { + val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") + generationField.isAccessible = true + (generationField.get(HermesWsClient) as AtomicInteger).get() + } + val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") + val constructor = listenerClass.declaredConstructors.single() + constructor.isAccessible = true + return constructor.newInstance(targetGen) as WebSocketListener + } + + private fun attachMockSocket(): WebSocket { + val socket = mockk(relaxed = true) + every { socket.send(any()) } returns true + + val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") + socketField.isAccessible = true + socketField.set(HermesWsClient, socket) + + val connectedField = HermesWsClient::class.java.getDeclaredField("connected") + connectedField.isAccessible = true + (connectedField.get(HermesWsClient) as AtomicBoolean).set(true) + + val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") + intentionalCloseField.isAccessible = true + (intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false) + + return socket + } + + @Suppress("UNCHECKED_CAST") + private fun getPendingCalls(): ConcurrentHashMap { + val field = HermesWsClient::class.java.getDeclaredField("pendingCalls") + field.isAccessible = true + return field.get(HermesWsClient) as ConcurrentHashMap + } + + private fun getTimeoutJob(pendingCall: Any): Job? { + val field = pendingCall.javaClass.getDeclaredField("timeoutJob") + field.isAccessible = true + return field.get(pendingCall) as? Job + } + + // ── Formatter unit tests ────────────────────────────────────────────────── + + @Test + fun `formatSafeOutgoingFrameLog does not leak payload parameters or urls`() { + val sensitiveUrl = "https://example.com/oauth/authorize?token=secret123" + val log = + HermesWsClient.formatSafeOutgoingFrameLog( + id = "req-42", + method = WsMethods.CONNECTORS_CONNECT, + paramsKeys = listOf("session_id", "connectors", "reconnect"), + byteLength = 256, + queued = false, + ) + + assertFalse("Log must not contain sensitive URL", log.contains(sensitiveUrl)) + assertFalse("Log must not contain 'secret'", log.contains("secret")) + assertTrue("Log should contain id", log.contains("id=req-42")) + assertTrue("Log should contain method", log.contains("method=connectors.connect")) + assertTrue( + "Log should contain parameter keys only", + log.contains("paramsKeys=[session_id,connectors,reconnect]"), + ) + assertTrue("Log should contain byte length", log.contains("bytes=256")) + } + + @Test + fun `formatSafeQueuedFrameLog does not leak sensitive payload`() { + val sensitiveUrl = "https://auth.provider.com/login?key=supersecret" + val rawJson = + buildJsonObject { + put("id", "q-1") + put("method", "connectors.connect") + put( + "params", + buildJsonObject { + put("session_id", "sid-1") + put("secret_url", sensitiveUrl) + }, + ) + }.toString() + + val log = HermesWsClient.formatSafeQueuedFrameLog(rawJson) + + assertFalse("Queued frame log must not leak sensitive url", log.contains(sensitiveUrl)) + assertFalse("Queued frame log must not contain 'supersecret'", log.contains("supersecret")) + assertTrue("Queued frame log should mark as queued", log.contains("→ (queued)")) + assertTrue("Queued frame log should show method", log.contains("method=connectors.connect")) + } + + @Test + fun `formatSafeIncomingFrameLog does not leak connect_url or token from response`() { + val sensitiveAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?client_id=123&token=abc_secret_token" + val responseJson = + """ + { + "jsonrpc": "2.0", + "id": "req-99", + "result": { + "results": [ + { + "connector": "google", + "status": "initiated", + "connect_url": "$sensitiveAuthUrl", + "instruction": "Open this link in your browser to sign in" + } + ], + "summary": {"total": 1} + } + } + """.trimIndent() + + val log = HermesWsClient.formatSafeIncomingFrameLog(responseJson) + + assertFalse("Incoming frame log must not leak sensitive auth url", log.contains(sensitiveAuthUrl)) + assertFalse("Incoming frame log must not leak secret token", log.contains("abc_secret_token")) + assertFalse("Incoming frame log must not leak instructions", log.contains("Open this link")) + assertTrue("Incoming frame log should contain id", log.contains("id=req-99")) + assertTrue("Incoming frame log should summarize result keys", log.contains("result=[results,summary]")) + } + + @Test + fun `formatSafeIncomingFrameLog handles malformed or non-object json safely`() { + val log1 = HermesWsClient.formatSafeIncomingFrameLog("plain string not json") + assertTrue(log1.startsWith("← frame bytes=")) + + val log2 = HermesWsClient.formatSafeIncomingFrameLog("[1, 2, 3]") + assertTrue(log2.startsWith("← non-object frame bytes=")) + } + + // ── Real inbound WsListenerImpl.onMessage path regression tests ─────────── + + @Test + fun `inbound onMessage delivers result with connect_url while Log_d redacts secrets`() { + val socket = attachMockSocket() + val listener = createWsListener() + + val deferred = HermesWsClient.request("connectors.connect", mapOf("connector" to "synthetic_provider")) + val pendingCalls = getPendingCalls() + val reqId = pendingCalls.keys.firstOrNull() + assertNotNull("Pending call ID must exist", reqId) + + val sensitiveAuthUrl = + "https://auth.synthetic-fixture.local/oauth/authorize?code=fixture-code-99&token=fixture-secret-token-xyz" + val responseJson = + """ + { + "jsonrpc": "2.0", + "id": "$reqId", + "result": { + "status": "initiated", + "connect_url": "$sensitiveAuthUrl" + } + } + """.trimIndent() + + listener.onMessage(socket, responseJson) + + assertTrue("Deferred request must be completed", deferred.isCompleted) + val result = runBlocking { deferred.await() } + assertTrue("Result must be delivered as JsonObject", result is JsonObject) + val resultObj = result as JsonObject + assertEquals( + sensitiveAuthUrl, + (resultObj["connect_url"] as? JsonPrimitive)?.content, + ) + + val allLogs = capturedDebugLogs + capturedInfoLogs + capturedWarnLogs + capturedErrorLogs + for (log in allLogs) { + assertFalse("Captured log must not leak sensitive auth URL: $log", log.contains(sensitiveAuthUrl)) + assertFalse("Captured log must not leak secret token: $log", log.contains("fixture-secret-token-xyz")) + assertFalse("Captured log must not leak fixture code: $log", log.contains("fixture-code-99")) + assertFalse( + "Captured log must not leak synthetic auth host: $log", + log.contains("https://auth.synthetic-fixture.local"), + ) + } + + assertTrue( + "Log.d must record safe sanitized incoming frame log with id and sanitized keys", + capturedDebugLogs.any { + it.contains("id=$reqId") && ( + it.contains("result=[status,connect_url]") || + it.contains("result=[connect_url,status]") + ) + }, + ) + } + + @Test + fun `inbound onMessage rpc error 4001 NOT_OWNER completes deferred exceptionally and preserves shared events`() { + val socket = attachMockSocket() + val listener = createWsListener() + + val capturedEvents = Collections.synchronizedList(mutableListOf()) + val subscribedLatch = CountDownLatch(1) + val errorEventLatch = CountDownLatch(1) + var receivedErrorEvent: WsEvent.RpcError? = null + + val collectJob = + CoroutineScope(Dispatchers.IO).launch { + HermesWsClient.events + .onSubscription { subscribedLatch.countDown() } + .collect { + capturedEvents.add(it) + if (it is WsEvent.RpcError) { + receivedErrorEvent = it + errorEventLatch.countDown() + } + } + } + + assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS)) + + val deferred = HermesWsClient.request("session.steer", mapOf("session_id" to "sess-ownership-4001")) + val pendingCalls = getPendingCalls() + val reqId = pendingCalls.keys.firstOrNull() + assertNotNull("Pending call ID must exist", reqId) + + val errorJson = + """ + { + "jsonrpc": "2.0", + "id": "$reqId", + "error": { + "code": 4001, + "message": "Access denied: session ownership required", + "data": { + "reason": "NOT_OWNER", + "session_id": "sess-ownership-4001" + } + } + } + """.trimIndent() + + listener.onMessage(socket, errorJson) + + assertTrue("Deferred must be completed", deferred.isCompleted) + val thrown = runCatching { runBlocking { deferred.await() } }.exceptionOrNull() + assertTrue( + "Deferred must complete exceptionally with HermesRpcException", + thrown is HermesWsClient.HermesRpcException, + ) + val rpcException = thrown as HermesWsClient.HermesRpcException + assertEquals(4001, rpcException.code) + assertEquals("Access denied: session ownership required", rpcException.message) + assertNotNull("Exception data must be preserved", rpcException.data) + assertTrue("Exception data must be JsonObject", rpcException.data is JsonObject) + val dataObj = rpcException.data as JsonObject + assertEquals("NOT_OWNER", (dataObj["reason"] as? JsonPrimitive)?.content) + assertEquals("sess-ownership-4001", (dataObj["session_id"] as? JsonPrimitive)?.content) + + assertTrue("Shared events flow must receive RpcError event", errorEventLatch.await(3, TimeUnit.SECONDS)) + collectJob.cancel() + + assertNotNull("Captured RpcError event must exist", receivedErrorEvent) + assertEquals(reqId, receivedErrorEvent!!.id) + assertEquals(4001, receivedErrorEvent!!.error.code) + assertEquals("Access denied: session ownership required", receivedErrorEvent!!.error.message) + val eventDataObj = receivedErrorEvent!!.error.data as? JsonObject + assertNotNull("Event error data must be JsonObject", eventDataObj) + assertEquals("NOT_OWNER", (eventDataObj!!["reason"] as? JsonPrimitive)?.content) + } + + @Test + fun `inbound onMessage malformed frame logs safely without leaking payload or secrets`() { + val socket = attachMockSocket() + val listener = createWsListener() + + val subscribedLatch = CountDownLatch(1) + val unknownEventLatch = CountDownLatch(1) + var receivedUnknownEvent: WsEvent.Unknown? = null + + val collectJob = + CoroutineScope(Dispatchers.IO).launch { + HermesWsClient.events + .onSubscription { subscribedLatch.countDown() } + .collect { + if (it is WsEvent.Unknown) { + receivedUnknownEvent = it + unknownEventLatch.countDown() + } + } + } + + assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS)) + capturedDebugLogs.clear() + capturedErrorLogs.clear() + + val sensitiveSecret = "synthetic-secret-token-malformed-frame-777" + val sensitiveUrl = "https://auth.synthetic-trap.local/oauth/secret" + val malformedPayload = """{"token": "$sensitiveSecret", "url": "$sensitiveUrl", [syntax-broken-json]}""" + + listener.onMessage(socket, malformedPayload) + + assertTrue("Unknown event must be emitted for malformed frame", unknownEventLatch.await(3, TimeUnit.SECONDS)) + collectJob.cancel() + + assertNotNull(receivedUnknownEvent) + assertEquals(malformedPayload, receivedUnknownEvent!!.raw) + + val allLogs = capturedDebugLogs + capturedInfoLogs + capturedWarnLogs + capturedErrorLogs + for (log in allLogs) { + assertFalse("Logs must not contain sensitive secret token: $log", log.contains(sensitiveSecret)) + assertFalse("Logs must not contain sensitive auth URL: $log", log.contains(sensitiveUrl)) + } + + assertTrue( + "Log.e must record parse failure without leaking secrets", + capturedErrorLogs.any { it.startsWith("Failed to parse message:") }, + ) + } + + @Test + fun `cancelling pending deferred cleans pending requests and cancels timeout job`() { + attachMockSocket() + val deferred = HermesWsClient.request("test.cancellation.method", mapOf("synthetic" to "fixture")) + val pendingCalls = getPendingCalls() + val entry = + pendingCalls.entries.firstOrNull { + it.value.javaClass.name + .contains("PendingCall") + } + assertNotNull("Pending call must be registered in pendingCalls map", entry) + val reqId = entry!!.key + val timeoutJob = getTimeoutJob(entry.value) + assertNotNull("Timeout job must be initialized", timeoutJob) + assertTrue("Timeout job must be active while request is pending", timeoutJob!!.isActive) + + deferred.cancel() + + assertTrue("Deferred must be cancelled", deferred.isCancelled) + assertFalse( + "Pending call must be removed from pendingCalls map on cancellation", + pendingCalls.containsKey(reqId), + ) + assertTrue("Timeout job must be cancelled on deferred cancellation", timeoutJob.isCancelled) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt index 0259ea7d..a0825212 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt @@ -161,6 +161,26 @@ class WsProfileParamsTest { assertSame(original, result) } + @Test + fun `connectors_list is not profile scoped`() { + AuthManager.setActiveProfileId("meow") + + val original = mapOf("session_id" to "sess-123") + val result = WsProfileParams.decorate(WsMethods.CONNECTORS_LIST, original) + + assertSame(original, result) + } + + @Test + fun `connectors_connect is not profile scoped`() { + AuthManager.setActiveProfileId("meow") + + val original = mapOf("session_id" to "sess-123", "connectors" to listOf("linear"), "reconnect" to false) + val result = WsProfileParams.decorate(WsMethods.CONNECTORS_CONNECT, original) + + assertSame(original, result) + } + @Test fun `unknown method is not profile scoped`() { AuthManager.setActiveProfileId("meow") diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegateTest.kt new file mode 100644 index 00000000..753e0a94 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsDelegateTest.kt @@ -0,0 +1,1403 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.ConnectorConnectItem +import com.m57.hermescontrol.data.model.ConnectorConnectResult +import com.m57.hermescontrol.data.model.ConnectorConnectStatus +import com.m57.hermescontrol.data.model.ConnectorConnectSummary +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorItem +import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.ws.ConnectorRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatConnectorsDelegateTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val mockRepository = mockk() + private var currentSessionId: String? = "session-A" + + private lateinit var delegate: ChatConnectorsDelegate + + @Before + fun setup() { + currentSessionId = "session-A" + coEvery { mockRepository.listConnectors(any()) } returns + ConnectorListResult.Success(available = true, connectors = emptyList()) + coEvery { mockRepository.connect(any(), any(), any()) } returns + ConnectorConnectResult.Success(results = emptyList(), summary = ConnectorConnectSummary()) + + delegate = + ChatConnectorsDelegate( + scope = testScope, + ioDispatcher = testDispatcher, + repository = mockRepository, + runtimeSessionId = { currentSessionId }, + ) + } + + private fun mockCatalog(vararg items: ConnectorItem) { + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Success(available = true, connectors = items.toList()) + } + + @Test + fun testInitialState_isNotVisible_andIdle() { + val state = delegate.uiState.value + assertFalse(state.isVisible) + assertEquals(ConnectorsLoadPhase.Initial, state.loadPhase) + assertTrue(state.items.isEmpty()) + assertEquals(ConnectorActionState.Idle, state.actionState) + assertNull(state.browserLaunchEvent) + assertNull(state.errorMessage) + assertFalse(state.isOldBackend) + } + + @Test + fun testShow_triggersRefreshWhenForeground() = + testScope.runTest { + mockCatalog( + ConnectorItem( + connector = "github", + name = "GitHub", + connected = false, + enabled = true, + ), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + val state = delegate.uiState.value + assertTrue(state.isVisible) + assertTrue(state.loadPhase is ConnectorsLoadPhase.Loaded) + assertEquals(1, state.items.size) + assertEquals("github", state.items[0].slug) + assertEquals("GitHub", state.items[0].name) + assertFalse(state.items[0].isConnected) + coVerify(atLeast = 1) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testNoSession_doesNotIssueRpc() = + testScope.runTest { + currentSessionId = null + delegate.onResume() + delegate.show() + delegate.refresh(force = true) + delegate.connect("github") + advanceUntilIdle() + + coVerify(exactly = 0) { mockRepository.listConnectors(any()) } + coVerify(exactly = 0) { mockRepository.connect(any(), any(), any()) } + } + + @Test + fun testHide_cancelsJobs_andResetsActionState() = + testScope.runTest { + val deferred = CompletableDeferred() + coEvery { mockRepository.listConnectors("session-A") } coAnswers { deferred.await() } + + delegate.onResume() + delegate.show() + testDispatcher.scheduler.runCurrent() + + assertTrue(delegate.uiState.value.loadPhase is ConnectorsLoadPhase.Loading) + + delegate.hide() + advanceUntilIdle() + + assertFalse(delegate.uiState.value.isVisible) + assertEquals(ConnectorActionState.Idle, delegate.uiState.value.actionState) + assertNull(delegate.uiState.value.browserLaunchEvent) + } + + @Test + fun testSessionReload_generationGuard_ignoresStaleSessionAResult() = + testScope.runTest { + val sessionADeferred1 = CompletableDeferred() + val sessionADeferred2 = CompletableDeferred() + var callCount = 0 + + coEvery { mockRepository.listConnectors("session-A") } coAnswers { + callCount++ + if (callCount == 1) { + sessionADeferred1.await() + } else { + sessionADeferred2.await() + } + } + coEvery { mockRepository.listConnectors("session-B") } returns + ConnectorListResult.Success( + available = true, + connectors = + listOf( + ConnectorItem(connector = "session-b-connector", name = "B"), + ), + ) + + delegate.onResume() + delegate.show() + testDispatcher.scheduler.runCurrent() + + // Transition: session A -> session B + currentSessionId = "session-B" + delegate.onActiveSessionChanged("session-B") + advanceUntilIdle() + + assertEquals(1, delegate.uiState.value.items.size) + assertEquals( + "session-b-connector", + delegate.uiState.value.items[0] + .slug, + ) + + // Transition: session B -> session A (new generation) + currentSessionId = "session-A" + delegate.onActiveSessionChanged("session-A") + testDispatcher.scheduler.runCurrent() + + // First session A call completes late! + sessionADeferred1.complete( + ConnectorListResult.Success( + available = true, + connectors = listOf(ConnectorItem(connector = "stale-item", name = "Stale")), + ), + ) + testDispatcher.scheduler.runCurrent() + + // Second session A call completes + sessionADeferred2.complete( + ConnectorListResult.Success( + available = true, + connectors = listOf(ConnectorItem(connector = "fresh-item", name = "Fresh")), + ), + ) + advanceUntilIdle() + + // Should have fresh-item, stale-item from previous generation must be dropped + assertEquals(1, delegate.uiState.value.items.size) + assertEquals( + "fresh-item", + delegate.uiState.value.items[0] + .slug, + ) + } + + @Test + fun testConnectUrl_validHttps_emitsBrowserEvent_awaitsAuthorization() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "slack", name = "Slack", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("slack"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "slack", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://slack.com/oauth/v2/authorize?client_id=123", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("slack") + advanceUntilIdle() + + val state = delegate.uiState.value + assertTrue(state.actionState is ConnectorActionState.AwaitingAuthorization) + assertEquals("slack", (state.actionState as ConnectorActionState.AwaitingAuthorization).slug) + + assertNotNull(state.browserLaunchEvent) + val event = state.browserLaunchEvent!! + assertEquals("slack", event.slug) + assertEquals("https://slack.com/oauth/v2/authorize?client_id=123", event.url) + assertTrue(event.toString().contains("[REDACTED]")) + assertFalse(event.toString().contains("client_id=123")) + + // Consume event + delegate.consumeBrowserEvent() + assertNull(delegate.uiState.value.browserLaunchEvent) + // Action state stays AwaitingAuthorization + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.AwaitingAuthorization) + } + + @Test + fun testConnectUrl_unsafeOrInvalid_resultsInRetryableFailure() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "bad", name = "Bad", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("bad"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "bad", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "http://insecure.com/oauth", // Insecure HTTP + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("bad") + advanceUntilIdle() + + val state = delegate.uiState.value + assertEquals(ConnectorActionState.Idle, state.actionState) + assertNull(state.browserLaunchEvent) + assertEquals("Invalid or unsafe authorization URL received.", state.errorMessage) + } + + @Test + fun testResume_whileAwaitingAuthorization_triggersRefreshOnce() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true, connected = false), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "github", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.AwaitingAuthorization) + assertNotNull(delegate.takeBrowserEvent()) + + // Simulate catalog returning connected = true on next fetch + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true, connected = true), + ) + + // Simulate user backgrounding app to browser + delegate.onPause() + // Returning from browser + delegate.onResume() + advanceUntilIdle() + + // List refresh confirmed it is now connected! Action state resets to Idle + val state = delegate.uiState.value + assertEquals(ConnectorActionState.Idle, state.actionState) + assertEquals(1, state.items.size) + assertTrue(state.items[0].isConnected) + coVerify(atLeast = 1) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testConnectActive_immediatelyRefreshesList() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "local", name = "Local Service", enabled = true, connected = false), + ) + coEvery { mockRepository.connect("session-A", listOf("local"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "local", + status = ConnectorConnectStatus.ACTIVE, + ), + ), + summary = ConnectorConnectSummary(total = 1, active = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // Update catalog to show connected = true after active connect + mockCatalog( + ConnectorItem(connector = "local", name = "Local Service", enabled = true, connected = true), + ) + + delegate.connect("local") + advanceUntilIdle() + + val state = delegate.uiState.value + assertEquals(ConnectorActionState.Idle, state.actionState) + assertEquals(1, state.items.size) + assertTrue(state.items[0].isConnected) + coVerify(atLeast = 1) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testFailures_retainCurrentItems_andCanRetry() = + testScope.runTest { + val initialItems = + listOf( + ConnectorItem( + connector = "existing", + name = "Existing Connector", + connected = false, + ), + ) + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Success(available = true, connectors = initialItems) andThen + ConnectorListResult.Error(ConnectorError.NetworkError()) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + assertEquals(1, delegate.uiState.value.items.size) + + // Second refresh fails with network error + delegate.refresh(force = true) + advanceUntilIdle() + + val state = delegate.uiState.value + // Existing items are preserved + assertEquals(1, state.items.size) + assertEquals("existing", state.items[0].slug) + assertTrue(state.loadPhase is ConnectorsLoadPhase.Error) + val errorPhase = state.loadPhase as ConnectorsLoadPhase.Error + assertTrue(errorPhase.canRetry) + assertTrue(errorPhase.error is ConnectorError.NetworkError) + } + + @Test + fun testOldBackend_setsFlag_andPreventsAutoRetries() = + testScope.runTest { + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Error(ConnectorError.UnsupportedBackend()) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + val state = delegate.uiState.value + assertTrue(state.isOldBackend) + assertTrue(state.loadPhase is ConnectorsLoadPhase.UnsupportedBackend) + + // Auto-retries (like onResume or onTransportReconnected) must NOT issue RPC + delegate.onPause() + delegate.onResume() + delegate.onTransportReconnected() + advanceUntilIdle() + + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testUnavailableAndUnsupportedRuntime_distinctPhases() = + testScope.runTest { + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Error(ConnectorError.Unavailable("Unavailable for this session.")) andThen + ConnectorListResult.Error(ConnectorError.UnsupportedRuntime("Must run on host.")) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + assertTrue(delegate.uiState.value.loadPhase is ConnectorsLoadPhase.Unavailable) + assertEquals( + "Unavailable for this session.", + (delegate.uiState.value.loadPhase as ConnectorsLoadPhase.Unavailable).message, + ) + + delegate.refresh(force = true) + advanceUntilIdle() + + assertTrue(delegate.uiState.value.loadPhase is ConnectorsLoadPhase.UnsupportedRuntime) + assertEquals( + "Must run on host.", + (delegate.uiState.value.loadPhase as ConnectorsLoadPhase.UnsupportedRuntime).message, + ) + } + + @Test + fun testNotOwner_explainsReconnectSession() = + testScope.runTest { + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Error(ConnectorError.NotOwner()) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + assertTrue(delegate.uiState.value.loadPhase is ConnectorsLoadPhase.NotOwner) + val msg = (delegate.uiState.value.loadPhase as ConnectorsLoadPhase.NotOwner).message + assertTrue(msg.contains("Please reconnect the session")) + } + + @Test + fun testRepeatTaps_connectDeduplication() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + val deferred = CompletableDeferred() + coEvery { mockRepository.connect("session-A", listOf("github"), false) } coAnswers { deferred.await() } + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + testDispatcher.scheduler.runCurrent() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.Connecting) + + // Second tap during Connecting should be ignored + delegate.connect("github") + testDispatcher.scheduler.runCurrent() + + coVerify(exactly = 1) { mockRepository.connect("session-A", listOf("github"), false) } + + deferred.complete( + ConnectorConnectResult.Success( + results = listOf(ConnectorConnectItem("github", ConnectorConnectStatus.FAILED)), + summary = ConnectorConnectSummary(1, failed = 1), + ), + ) + advanceUntilIdle() + } + + @Test + fun testRefreshDuringMutation_isDeferredUntilMutationCompletes() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true, connected = false), + ) + val connectDeferred = CompletableDeferred() + coEvery { mockRepository.connect("session-A", listOf("github"), false) } coAnswers + { connectDeferred.await() } + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // 1 call from show + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + delegate.connect("github") + testDispatcher.scheduler.runCurrent() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.Connecting) + + // Call refresh while connecting + delegate.refresh() + testDispatcher.scheduler.runCurrent() + + // Refresh should not have run yet while mutation is active (still exactly 1) + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + // Finish connect + connectDeferred.complete( + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + "github", + ConnectorConnectStatus.INITIATED, + "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(1, initiated = 1), + ), + ) + advanceUntilIdle() + + // Deferred refresh should have executed! (now 2) + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testLaunchError_resetsActionStateAndSetsError() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + "github", + ConnectorConnectStatus.INITIATED, + "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.AwaitingAuthorization) + assertNotNull(delegate.uiState.value.browserLaunchEvent) + + delegate.launchError("No browser app found to handle OAuth.") + + val state = delegate.uiState.value + assertEquals(ConnectorActionState.Idle, state.actionState) + assertNull(state.browserLaunchEvent) + assertEquals("No browser app found to handle OAuth.", state.errorMessage) + } + + @Test + fun testOnPause_clearsPendingBrowserEvent() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + "github", + ConnectorConnectStatus.INITIATED, + "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + assertNotNull(delegate.uiState.value.browserLaunchEvent) + + delegate.onPause() + assertNull(delegate.uiState.value.browserLaunchEvent) + } + + @Test + fun testConnect_notOwner_neverReplaysMutationIntoSessionB() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Error(ConnectorError.NotOwner()) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // Connect is invoked for session-A + delegate.connect("github") + // Active session shifts to session-B + currentSessionId = "session-B" + advanceUntilIdle() + + // Critical regression check: MUST NEVER execute connect mutation on session-B! + coVerify(exactly = 0) { mockRepository.connect("session-B", any(), any()) } + assertEquals(ConnectorActionState.Idle, delegate.uiState.value.actionState) + } + + @Test + fun testConnect_notOwner_setsNotOwnerPhaseAndErrorWithoutMutationRetry() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Error(ConnectorError.NotOwner()) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + coVerify(exactly = 1) { mockRepository.connect("session-A", listOf("github"), false) } + val state = delegate.uiState.value + assertEquals(ConnectorActionState.Idle, state.actionState) + assertTrue(state.loadPhase is ConnectorsLoadPhase.NotOwner) + assertTrue(state.errorMessage?.contains("reconnect the session") == true) + } + + @Test + fun testConnect_fallbackFirstUnrelatedConnectorRemoved_requiresExactSlugMatch() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "slack", name = "Slack", enabled = true), + ) + // Backend returns unrelated connector "other" instead of "slack" + coEvery { mockRepository.connect("session-A", listOf("slack"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "other", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://other.com/oauth", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("slack") + advanceUntilIdle() + + val state = delegate.uiState.value + // Fallback must NOT trigger authorization for "other" + assertEquals(ConnectorActionState.Idle, state.actionState) + assertNull(state.browserLaunchEvent) + assertEquals("Connector 'slack' not found in response.", state.errorMessage) + } + + @Test + fun testConnect_guardsDisabledAndNonCatalogSlug() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "enabled-item", name = "Enabled", enabled = true), + ConnectorItem(connector = "disabled-item", name = "Disabled", enabled = false), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // Slug not in catalog + delegate.connect("unknown-item") + advanceUntilIdle() + assertEquals("Connector 'unknown-item' not found in catalog.", delegate.uiState.value.errorMessage) + coVerify(exactly = 0) { mockRepository.connect(any(), listOf("unknown-item"), any()) } + + // Slug disabled in catalog + delegate.connect("disabled-item") + advanceUntilIdle() + assertEquals("Connector 'disabled-item' is disabled.", delegate.uiState.value.errorMessage) + coVerify(exactly = 0) { mockRepository.connect(any(), listOf("disabled-item"), any()) } + } + + @Test + fun testConnect_guardsBackgroundAndOldBackend() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // Background guard + delegate.onPause() + delegate.connect("github") + advanceUntilIdle() + coVerify(exactly = 0) { mockRepository.connect(any(), any(), any()) } + + // UnsupportedBackend guard + delegate.onResume() + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Error(ConnectorError.UnsupportedBackend("Old server")) + delegate.refresh(force = true) + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + coVerify(exactly = 0) { mockRepository.connect(any(), any(), any()) } + assertEquals("Connectors are not supported by this backend.", delegate.uiState.value.errorMessage) + } + + @Test + fun testRefresh_coalescesAndDeduplicatesForceCalls() = + testScope.runTest { + val deferred = CompletableDeferred() + coEvery { mockRepository.listConnectors("session-A") } coAnswers { deferred.await() } + + delegate.onResume() + delegate.show() + testDispatcher.scheduler.runCurrent() + + // Rapid repeat calls to refresh(force = true) while in flight + delegate.refresh(force = true) + delegate.refresh(force = true) + testDispatcher.scheduler.runCurrent() + + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + deferred.complete( + ConnectorListResult.Success(available = true, connectors = emptyList()), + ) + advanceUntilIdle() + } + + @Test + fun testOnResume_duplicateCallsDoNotRepeatForcedRefresh() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + "github", + ConnectorConnectStatus.INITIATED, + "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + // 1 refresh from show + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + // Take browser event to simulate launching external browser + assertNotNull(delegate.takeBrowserEvent()) + + // App goes to background (user in browser) + delegate.onPause() + // App returns: true paused -> resume transition triggers refresh once + delegate.onResume() + advanceUntilIdle() + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + + // Duplicate onResume while already foreground MUST NOT fire forced refresh again + delegate.onResume() + delegate.onResume() + advanceUntilIdle() + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testConsumeBrowserEvent_validatesIdentitySessionGenerationAndForeground() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + "github", + ConnectorConnectStatus.INITIATED, + "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + val event = delegate.uiState.value.browserLaunchEvent + assertNotNull(event) + val eventId = event!!.eventId + + // 1. Wrong event ID returns null + assertNull(delegate.consumeBrowserEvent(999999L)) + assertNotNull(delegate.uiState.value.browserLaunchEvent) + + // 2. Consume while backgrounded rejects handoff and clears stale event + delegate.onPause() + assertNull(delegate.consumeBrowserEvent(eventId)) + assertNull(delegate.uiState.value.browserLaunchEvent) + } + + @Test + fun testCatalogRefresh_removedConnectorEndsAuthorization() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true, connected = false), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + "github", + ConnectorConnectStatus.INITIATED, + "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.AwaitingAuthorization) + assertNotNull(delegate.takeBrowserEvent()) + + // Refresh returns catalog where "github" was completely removed + mockCatalog( + ConnectorItem(connector = "slack", name = "Slack", enabled = true), + ) + delegate.onPause() + delegate.onResume() + advanceUntilIdle() + + // Authorization should end cleanly, avoiding infinite spinner + assertEquals(ConnectorActionState.Idle, delegate.uiState.value.actionState) + } + + @Test + fun testRefreshSuccess_clearsPreviousErrorMessage() = + testScope.runTest { + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Error(ConnectorError.NetworkError("Network lost")) andThen + ConnectorListResult.Success(available = true, connectors = emptyList()) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.launchError("Temporary failure") + assertEquals("Temporary failure", delegate.uiState.value.errorMessage) + + delegate.refresh(force = true) + advanceUntilIdle() + + assertNull(delegate.uiState.value.errorMessage) + } + + @Test + fun testAvailableFalse_mapsToUnavailablePhaseAndClearsItems() = + testScope.runTest { + coEvery { mockRepository.listConnectors("session-A") } returns + ConnectorListResult.Success( + available = false, + connectors = listOf(ConnectorItem("github", "GitHub")), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + val state = delegate.uiState.value + assertTrue(state.loadPhase is ConnectorsLoadPhase.Unavailable) + assertTrue(state.items.isEmpty()) + } + + @Test + fun testHide_incrementsGeneration_andIgnoresLateResponses() = + testScope.runTest { + val deferred = CompletableDeferred() + coEvery { mockRepository.listConnectors("session-A") } coAnswers { deferred.await() } + + delegate.onResume() + delegate.show() + testDispatcher.scheduler.runCurrent() + + delegate.hide() + + deferred.complete( + ConnectorListResult.Success( + available = true, + connectors = listOf(ConnectorItem("github", "GitHub")), + ), + ) + advanceUntilIdle() + + // State must remain hidden with initial/empty items + assertFalse(delegate.uiState.value.isVisible) + assertTrue( + delegate.uiState.value.items + .isEmpty(), + ) + } + + @Test + fun testReadCompletesPaused_deliversCatalog_noPermanentSpinnerOnResume() = + testScope.runTest { + val deferred = CompletableDeferred() + coEvery { mockRepository.listConnectors("session-A") } coAnswers { deferred.await() } + + delegate.onResume() + delegate.show() + testDispatcher.scheduler.runCurrent() + + assertTrue(delegate.uiState.value.loadPhase is ConnectorsLoadPhase.Loading) + assertTrue(delegate.uiState.value.isLoading) + + // Background while read is in-flight + delegate.onPause() + + // Read completes while app is paused + deferred.complete( + ConnectorListResult.Success( + available = true, + connectors = listOf(ConnectorItem(connector = "github", name = "GitHub", enabled = true)), + ), + ) + advanceUntilIdle() + + // State delivered while paused; not stuck in permanent spinner! + val pausedState = delegate.uiState.value + assertTrue(pausedState.loadPhase is ConnectorsLoadPhase.Loaded) + assertFalse(pausedState.isLoading) + assertEquals(1, pausedState.items.size) + assertEquals("github", pausedState.items[0].slug) + + // Returning to foreground retains loaded catalog without infinite reload spinner + delegate.onResume() + advanceUntilIdle() + + val resumedState = delegate.uiState.value + assertTrue(resumedState.loadPhase is ConnectorsLoadPhase.Loaded) + assertFalse(resumedState.isLoading) + assertEquals(1, resumedState.items.size) + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + } + + @Test + fun testConnectActive_completedWhilePaused_defersRefreshUntilResume() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "local", name = "Local Service", enabled = true, connected = false), + ) + val connectDeferred = CompletableDeferred() + coEvery { mockRepository.connect("session-A", listOf("local"), false) } coAnswers { + connectDeferred.await() + } + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + delegate.connect("local") + testDispatcher.scheduler.runCurrent() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.Connecting) + + // App is paused while connect mutation is pending + delegate.onPause() + + // Mutation completes with ACTIVE while app is in background + connectDeferred.complete( + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "local", + status = ConnectorConnectStatus.ACTIVE, + ), + ), + summary = ConnectorConnectSummary(total = 1, active = 1), + ), + ) + advanceUntilIdle() + + // Critical: NO background refresh RPC issued while paused! + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + assertEquals(ConnectorActionState.Idle, delegate.uiState.value.actionState) + + // Update mock catalog so refresh returns connected = true + mockCatalog( + ConnectorItem(connector = "local", name = "Local Service", enabled = true, connected = true), + ) + + // Resume triggers the deferred refresh + delegate.onResume() + advanceUntilIdle() + + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + val state = delegate.uiState.value + assertEquals(1, state.items.size) + assertTrue(state.items[0].isConnected) + assertEquals(ConnectorActionState.Idle, state.actionState) + } + + @Test + fun testMintedUrl_unconsumedOnPause_retainedTransiently_onlyActualBrowserReturnTriggersVerification() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true, connected = false), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "github", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // 1 call from show + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + delegate.connect("github") + advanceUntilIdle() + + val mintedEvent = delegate.uiState.value.browserLaunchEvent + assertNotNull(mintedEvent) + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.AwaitingAuthorization) + + // App paused BEFORE event is consumed/launched (e.g. system dialog or user home tap) + delegate.onPause() + + // In background: transient event is hidden from UI state + assertNull(delegate.uiState.value.browserLaunchEvent) + + // App resumed WITHOUT having launched the browser + delegate.onResume() + advanceUntilIdle() + + // Critical: MUST NOT trigger verification refresh before launch! + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + // Minted unconsumed event is restored on resume + val restoredEvent = delegate.uiState.value.browserLaunchEvent + assertNotNull(restoredEvent) + assertEquals(mintedEvent!!.eventId, restoredEvent!!.eventId) + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.AwaitingAuthorization) + + // Now UI actually consumes event to launch browser + val taken = delegate.takeBrowserEvent() + assertNotNull(taken) + assertEquals(mintedEvent.eventId, taken!!.eventId) + assertNull(delegate.uiState.value.browserLaunchEvent) + + // Taking again returns null (taken only once) + assertNull(delegate.takeBrowserEvent()) + + // Simulate catalog returning connected = true on next fetch + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true, connected = true), + ) + + // App paused as user transitions to browser + delegate.onPause() + + // App resumed upon returning from external browser authorization + delegate.onResume() + advanceUntilIdle() + + // Verification refresh IS triggered upon actual browser return! + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + val finalState = delegate.uiState.value + assertEquals(ConnectorActionState.Idle, finalState.actionState) + assertTrue(finalState.items[0].isConnected) + } + + @Test + fun testForcedRefresh_coalescesToOneTrailingRead_notCancelRestartStorm() = + testScope.runTest { + val deferred1 = CompletableDeferred() + val deferred2 = CompletableDeferred() + var listCallCount = 0 + + coEvery { mockRepository.listConnectors("session-A") } coAnswers { + listCallCount++ + if (listCallCount == 1) { + deferred1.await() + } else { + deferred2.await() + } + } + + delegate.onResume() + delegate.show() + testDispatcher.scheduler.runCurrent() + + assertEquals(1, listCallCount) + + // Rapid storm of forced refresh calls while read is in-flight + delegate.refresh(force = true) + delegate.refresh(force = true) + delegate.refresh(force = true) + testDispatcher.scheduler.runCurrent() + + // In-flight read was NOT cancelled or multiplied + assertEquals(1, listCallCount) + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + // Complete first read + deferred1.complete( + ConnectorListResult.Success(available = true, connectors = emptyList()), + ) + testDispatcher.scheduler.runCurrent() + + // Exactly ONE trailing read was started, not 3! + assertEquals(2, listCallCount) + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + + // Complete second (trailing) read + deferred2.complete( + ConnectorListResult.Success( + available = true, + connectors = listOf(ConnectorItem(connector = "done", name = "Done")), + ), + ) + advanceUntilIdle() + + // No additional reads + assertEquals(2, listCallCount) + assertEquals(1, delegate.uiState.value.items.size) + assertEquals( + "done", + delegate.uiState.value.items[0] + .slug, + ) + } + + @Test + fun testActivePendingMutation_sessionChanged_doesNotReplayIntoSessionB() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + val connectDeferred = CompletableDeferred() + coEvery { mockRepository.connect("session-A", listOf("github"), false) } coAnswers { + connectDeferred.await() + } + coEvery { mockRepository.listConnectors("session-B") } returns + ConnectorListResult.Success( + available = true, + connectors = listOf(ConnectorItem(connector = "slack", name = "Slack", enabled = true)), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + // Start connect mutation on session-A + delegate.connect("github") + testDispatcher.scheduler.runCurrent() + + assertTrue(delegate.uiState.value.actionState is ConnectorActionState.Connecting) + + // Switch active session to session-B while mutation is still pending + currentSessionId = "session-B" + delegate.onActiveSessionChanged("session-B") + testDispatcher.scheduler.runCurrent() + + // Now pending connect mutation for session-A finishes + connectDeferred.complete( + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "github", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ), + ) + advanceUntilIdle() + + // Critical: MUST NEVER replay mutation into session-B! + coVerify(exactly = 0) { mockRepository.connect("session-B", any(), any()) } + + // State reflects session-B, session-A's pending mutation is completely discarded + val state = delegate.uiState.value + assertEquals("session-B", state.sessionId) + assertEquals(ConnectorActionState.Idle, state.actionState) + assertNull(state.browserLaunchEvent) + assertEquals(1, state.items.size) + assertEquals("slack", state.items[0].slug) + } + + @Test + fun testPendingBrowserEvent_publishedResumedTakenOnlyOnce_sessionSwitchDiscards() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "github", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + // 1. Published into UI state + assertNotNull(delegate.uiState.value.browserLaunchEvent) + + // 2. Background app: unconsumed event retained transiently + delegate.onPause() + assertNull(delegate.uiState.value.browserLaunchEvent) + + // 3. Switch active session while backgrounded + currentSessionId = "session-B" + delegate.onActiveSessionChanged("session-B") + + // 4. Resume in new session: old session's pending event MUST be discarded + delegate.onResume() + advanceUntilIdle() + + assertNull(delegate.uiState.value.browserLaunchEvent) + assertNull(delegate.takeBrowserEvent()) + + // Switch back to session-A + currentSessionId = "session-A" + delegate.onActiveSessionChanged("session-A") + advanceUntilIdle() + + delegate.connect("github") + advanceUntilIdle() + + // Published again in session-A + assertNotNull(delegate.uiState.value.browserLaunchEvent) + + // Background & Resume + delegate.onPause() + delegate.onResume() + assertNotNull(delegate.uiState.value.browserLaunchEvent) + + // Taken only once + val taken = delegate.takeBrowserEvent() + assertNotNull(taken) + assertNull(delegate.uiState.value.browserLaunchEvent) + assertNull(delegate.takeBrowserEvent()) + + // Subsequent onResume does not re-publish already-taken event + delegate.onPause() + delegate.onResume() + assertNull(delegate.uiState.value.browserLaunchEvent) + } + + @Test + fun testLifecycle_repeatedCallsDoNotDuplicateEventsOrRefreshes() = + testScope.runTest { + mockCatalog( + ConnectorItem(connector = "github", name = "GitHub", enabled = true), + ) + coEvery { mockRepository.connect("session-A", listOf("github"), false) } returns + ConnectorConnectResult.Success( + results = + listOf( + ConnectorConnectItem( + connector = "github", + status = ConnectorConnectStatus.INITIATED, + connectUrl = "https://github.com/login/oauth/authorize", + ), + ), + summary = ConnectorConnectSummary(total = 1, initiated = 1), + ) + + delegate.onResume() + delegate.show() + advanceUntilIdle() + + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + delegate.connect("github") + advanceUntilIdle() + + val originalEvent = delegate.uiState.value.browserLaunchEvent + assertNotNull(originalEvent) + + // Repeated onPause calls do not corrupt or lose the event + delegate.onPause() + delegate.onPause() + delegate.onPause() + assertNull(delegate.uiState.value.browserLaunchEvent) + + // Repeated onResume calls do not duplicate event or trigger repeated refresh + delegate.onResume() + delegate.onResume() + delegate.onResume() + advanceUntilIdle() + + assertEquals( + originalEvent!!.eventId, + delegate.uiState.value.browserLaunchEvent + ?.eventId, + ) + coVerify(exactly = 1) { mockRepository.listConnectors("session-A") } + + // Take the event + assertNotNull(delegate.takeBrowserEvent()) + + // Repeated pause/resume cycle after launch triggers verification refresh exactly once + delegate.onPause() + delegate.onResume() + delegate.onResume() + delegate.onResume() + advanceUntilIdle() + + coVerify(exactly = 2) { mockRepository.listConnectors("session-A") } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModelTest.kt new file mode 100644 index 00000000..048ffbc4 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModelTest.kt @@ -0,0 +1,146 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesWsClient +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatConnectorsViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val mockDelegate = mockk(relaxed = true) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + ActiveSessionHolder.clear() + HermesWsClient.setConnectionStatusForTest(ConnectionStatus.DISCONNECTED) + } + + @After + fun tearDown() { + ActiveSessionHolder.clear() + HermesWsClient.setConnectionStatusForTest(ConnectionStatus.DISCONNECTED) + AuthManager.resetAuthStateForTest() + Dispatchers.resetMain() + } + + @Test + fun testInitialSync_triggersOnActiveSessionChangedWithInitialSession() { + ActiveSessionHolder.set("sess-init") + + ChatConnectorsViewModel(delegate = mockDelegate) + testDispatcher.scheduler.advanceUntilIdle() + + verify { mockDelegate.onActiveSessionChanged("sess-init") } + } + + @Test + fun testSessionChange_triggersOnActiveSessionChanged() { + ActiveSessionHolder.set("sess-1") + + ChatConnectorsViewModel(delegate = mockDelegate) + testDispatcher.scheduler.advanceUntilIdle() + + ActiveSessionHolder.set("sess-2") + testDispatcher.scheduler.advanceUntilIdle() + + verify { mockDelegate.onActiveSessionChanged("sess-2") } + } + + @Test + fun testProfileChange_triggersOnActiveSessionChanged() { + ActiveSessionHolder.set("sess-1") + + ChatConnectorsViewModel(delegate = mockDelegate) + testDispatcher.scheduler.advanceUntilIdle() + + AuthManager.setActiveProfileId("profile-new") + testDispatcher.scheduler.advanceUntilIdle() + + verify(atLeast = 1) { mockDelegate.onActiveSessionChanged("sess-1") } + } + + @Test + fun testServerChange_triggersOnActiveSessionChanged() { + ActiveSessionHolder.set("sess-1") + + ChatConnectorsViewModel(delegate = mockDelegate) + testDispatcher.scheduler.advanceUntilIdle() + + AuthManager.setBaseUrlForTest("http://192.168.1.199:8080") + testDispatcher.scheduler.advanceUntilIdle() + + verify(atLeast = 1) { mockDelegate.onActiveSessionChanged("sess-1") } + } + + @Test + fun testReconnect_triggersOnTransportReconnected() { + ChatConnectorsViewModel(delegate = mockDelegate) + testDispatcher.scheduler.advanceUntilIdle() + + HermesWsClient.setConnectionStatusForTest(ConnectionStatus.CONNECTED) + testDispatcher.scheduler.advanceUntilIdle() + + verify { mockDelegate.onTransportReconnected() } + } + + @Test + fun testForwardsUiMethodsToDelegate() { + val uiStateFlow = MutableStateFlow(ChatConnectorsUiState(isVisible = true)) + val delegate = + mockk(relaxed = true) { + io.mockk.every { uiState } returns uiStateFlow + io.mockk.every { takeBrowserEvent(any()) } returns + ConnectBrowserEvent( + slug = "slug", + url = "https://auth.example.com", + ) + } + + val viewModel = ChatConnectorsViewModel(delegate = delegate) + + assertEquals(true, viewModel.uiState.value.isVisible) + + viewModel.show() + verify { delegate.show() } + + viewModel.hide() + verify { delegate.hide() } + + viewModel.onResume() + verify { delegate.onResume() } + + viewModel.onPause() + verify { delegate.onPause() } + + viewModel.refresh(force = true) + verify { delegate.refresh(force = true) } + + viewModel.connect("github", reconnect = true) + verify { delegate.connect("github", reconnect = true) } + + val event = viewModel.takeBrowserEvent(123L) + assertEquals("slug", event?.slug) + verify { delegate.takeBrowserEvent(123L) } + + viewModel.launchError("test error") + verify { delegate.launchError("test error") } + + viewModel.clearError() + verify { delegate.clearError() } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/util/ConnectorUrlValidatorTest.kt b/app/src/test/java/com/m57/hermescontrol/util/ConnectorUrlValidatorTest.kt new file mode 100644 index 00000000..851e03b5 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/util/ConnectorUrlValidatorTest.kt @@ -0,0 +1,68 @@ +package com.m57.hermescontrol.util + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectorUrlValidatorTest { + @Test + fun `valid https urls are accepted`() { + assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://auth.hermes.nousresearch.com/oauth/google")) + assertTrue( + ConnectorUrlValidator.isValidHttpsUrl("https://github.com/login/oauth/authorize?client_id=123&scope=repo"), + ) + assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://linear.app/oauth/authorize?response_type=code")) + assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://slack.com/oauth/v2/authorize")) + assertTrue(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:8443/auth?foo=bar#section")) + assertTrue(ConnectorUrlValidator.isValidHttpsUrl("HTTPS://EXAMPLE.COM/AUTH")) + } + + @Test + fun `null, empty, and whitespace strings are rejected`() { + assertFalse(ConnectorUrlValidator.isValidHttpsUrl(null)) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl(" ")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("\t\n")) + } + + @Test + fun `non-https schemes are rejected`() { + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("http://example.com/auth")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("ftp://example.com/file")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("javascript:alert(1)")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("data:text/html,")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("file:///etc/passwd")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("intent://example.com")) + } + + @Test + fun `urls with userinfo are rejected`() { + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://user:password@example.com/oauth")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://user@example.com/oauth")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://admin:@example.com/oauth")) + } + + @Test + fun `urls with backslashes are rejected`() { + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com\\evil.com")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/path\\something")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https:\\\\example.com")) + } + + @Test + fun `urls with control characters or whitespace are rejected`() { + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/auth\u0000")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/auth\r\nSet-Cookie:bad")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com/auth test")) + } + + @Test + fun `urls with invalid host or port are rejected`() { + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https:///path")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:0/auth")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:65536/auth")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://example.com:99999/auth")) + assertFalse(ConnectorUrlValidator.isValidHttpsUrl("https://invalid_host_chars!/auth")) + } +} From 7e8af8db01a9de6a0f70f396f10079418b2a7d08 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:45:33 +0300 Subject: [PATCH 39/42] feat(sessions): show live status for active sessions in History (#1101) (#1104) --- .../SessionLiveStatusIndicatorTest.kt | 180 +++++++++++++ .../data/model/SessionLiveStatus.kt | 41 +++ .../data/ws/SessionLiveStatusSource.kt | 169 ++++++++++++ .../ui/sessions/SessionLiveStatusReducer.kt | 156 +++++++++++ .../ui/sessions/SessionsScreen.kt | 10 + .../ui/sessions/SessionsViewModel.kt | 124 ++++++++- .../sessions/components/SearchResultCard.kt | 14 + .../ui/sessions/components/SessionCard.kt | 19 +- .../components/SessionLiveStatusIndicator.kt | 112 ++++++++ app/src/main/res/values-ar/strings.xml | 4 + app/src/main/res/values-ko/strings.xml | 4 + app/src/main/res/values-zh/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../data/ws/SessionLiveStatusSourceTest.kt | 168 ++++++++++++ .../sessions/SessionLiveStatusReducerTest.kt | 239 +++++++++++++++++ .../ui/sessions/SessionsViewModelTest.kt | 249 +++++++++++++++++- 16 files changed, 1485 insertions(+), 12 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusIndicatorTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducer.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducerTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusIndicatorTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusIndicatorTest.kt new file mode 100644 index 00000000..2a10a610 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusIndicatorTest.kt @@ -0,0 +1,180 @@ +package com.m57.hermescontrol.ui.sessions + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.ui.sessions.components.SearchResultCard +import com.m57.hermescontrol.ui.sessions.components.SessionCard +import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class SessionLiveStatusIndicatorTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun workingStatus_displaysRunningIndicator() { + composeTestRule.setContent { + HermesControlTheme { + SessionLiveStatusIndicator( + liveStatus = SessionLiveStatus.WORKING, + sessionId = "sess-working", + ) + } + } + + composeTestRule.onNodeWithTag("session_live_status_sess-working").assertIsDisplayed() + composeTestRule.onNodeWithText("Running").assertIsDisplayed() + } + + @Test + fun waitingStatus_displaysNeedsInputIndicator() { + composeTestRule.setContent { + HermesControlTheme { + SessionLiveStatusIndicator( + liveStatus = SessionLiveStatus.WAITING, + sessionId = "sess-waiting", + ) + } + } + + composeTestRule.onNodeWithTag("session_live_status_sess-waiting").assertIsDisplayed() + composeTestRule.onNodeWithText("Needs input").assertIsDisplayed() + } + + @Test + fun sessionCard_withLiveStatus_displaysIndicator() { + val session = + SessionInfo( + id = "sess-card-working", + title = "Working session", + message_count = 5, + ) + + composeTestRule.setContent { + HermesControlTheme { + SessionCard( + session = session, + displayTitle = "Working session", + branchStem = null, + isFork = false, + forkDepth = 0, + query = "", + isSelecting = false, + isSelected = false, + isDeleting = false, + isPinned = false, + isHidden = false, + liveStatus = SessionLiveStatus.WORKING, + highlightBackground = Color.Transparent, + highlightForeground = Color.Transparent, + onCardClick = {}, + onToggleSelection = {}, + onSelect = {}, + onRename = {}, + onTogglePin = {}, + onToggleHide = {}, + onDelete = {}, + ) + } + } + + composeTestRule.onNodeWithTag("session_card_sess-card-working").assertIsDisplayed() + composeTestRule + .onNodeWithTag( + "session_live_status_sess-card-working", + useUnmergedTree = true, + ).assertIsDisplayed() + composeTestRule.onNodeWithText("Running", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun sessionCard_withoutLiveStatus_hidesIndicator() { + val session = + SessionInfo( + id = "sess-card-idle", + title = "Idle session", + message_count = 2, + ) + + composeTestRule.setContent { + HermesControlTheme { + SessionCard( + session = session, + displayTitle = "Idle session", + branchStem = null, + isFork = false, + forkDepth = 0, + query = "", + isSelecting = false, + isSelected = false, + isDeleting = false, + isPinned = false, + isHidden = false, + liveStatus = null, + highlightBackground = Color.Transparent, + highlightForeground = Color.Transparent, + onCardClick = {}, + onToggleSelection = {}, + onSelect = {}, + onRename = {}, + onTogglePin = {}, + onToggleHide = {}, + onDelete = {}, + ) + } + } + + composeTestRule.onNodeWithTag("session_card_sess-card-idle").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_live_status_sess-card-idle").assertDoesNotExist() + } + + @Test + fun searchResultCard_withLiveStatus_displaysIndicator() { + val session = + SessionInfo( + id = "sess-search-waiting", + preview = "Searching something", + source = "web", + ) + + composeTestRule.setContent { + HermesControlTheme { + SearchResultCard( + session = session, + query = "Searching", + isSelecting = false, + isSelected = false, + isDeleting = false, + liveStatus = SessionLiveStatus.WAITING, + highlightBackground = Color.Transparent, + highlightForeground = Color.Transparent, + onCardClick = {}, + onToggleSelection = {}, + onSelect = {}, + onRename = {}, + onDelete = {}, + ) + } + } + + composeTestRule.onNodeWithTag("session_card_sess-search-waiting").assertIsDisplayed() + composeTestRule + .onNodeWithTag( + "session_live_status_sess-search-waiting", + useUnmergedTree = true, + ).assertIsDisplayed() + composeTestRule.onNodeWithText("Needs input", useUnmergedTree = true).assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt new file mode 100644 index 00000000..3f201350 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt @@ -0,0 +1,41 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * High-level live status for a session in History. + */ +enum class SessionLiveStatus { + WORKING, + WAITING, +} + +/** + * RPC response from `session.active_list`. + */ +@Serializable +data class ActiveSessionsResponse( + val sessions: List = emptyList(), +) + +/** + * Individual item in `session.active_list`. + */ +@Serializable +data class ActiveSessionItem( + val id: String? = null, + @SerialName("session_key") + val sessionKey: String? = null, + val status: String? = null, + @SerialName("last_active") + val lastActive: Double? = null, +) + +/** + * Parsed authoritative snapshot of live sessions. + */ +data class LiveSessionSnapshot( + val statusByStoredId: Map = emptyMap(), + val storedIdByRuntimeId: Map = emptyMap(), +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt new file mode 100644 index 00000000..31cc88b4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt @@ -0,0 +1,169 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ActiveSessionsResponse +import com.m57.hermescontrol.data.model.LiveSessionSnapshot +import com.m57.hermescontrol.data.model.SessionLiveStatus +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.serializer + +/** + * Interface for polling and observing live session statuses. + */ +interface SessionLiveStatusSource { + suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? + + val events: Flow + val connectionStatus: StateFlow +} + +/** + * Decodes untyped RPC responses from `session.active_list` into a [LiveSessionSnapshot]. + */ +object SessionLiveStatusDecoder { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + } + + fun decodeSnapshot(rawResult: Any?): LiveSessionSnapshot? { + if (rawResult == null) return null + val response = + try { + decodeResponse(rawResult) + } catch (_: Exception) { + return null + } ?: return null + + val statusByStoredId = mutableMapOf() + val storedIdByRuntimeId = mutableMapOf() + + for (item in response.sessions) { + val runtimeId = item.id?.trim() + val storedId = item.sessionKey?.trim() + if (runtimeId.isNullOrEmpty() || storedId.isNullOrEmpty()) { + continue + } + storedIdByRuntimeId[runtimeId] = storedId + when (item.status?.trim()?.lowercase()) { + "working" -> { + statusByStoredId[storedId] = SessionLiveStatus.WORKING + } + + "waiting" -> { + statusByStoredId[storedId] = SessionLiveStatus.WAITING + } + + else -> { + // idle, starting, unknown -> no live indicator + } + } + } + + return LiveSessionSnapshot( + statusByStoredId = statusByStoredId, + storedIdByRuntimeId = storedIdByRuntimeId, + ) + } + + private fun decodeResponse(result: Any): ActiveSessionsResponse? { + val element: JsonElement = + when (result) { + is JsonElement -> { + result + } + + is Map<*, *> -> { + anyToJsonElement(result) + } + + is List<*> -> { + JsonArray(result.map { anyToJsonElement(it) }) + } + + else -> { + val str = result.toString() + try { + json.parseToJsonElement(str) + } catch (_: Exception) { + return null + } + } + } + if (element !is JsonObject || !element.containsKey("sessions")) { + return null + } + return json.decodeFromJsonElement(serializer(), element) + } + + @Suppress("UNCHECKED_CAST") + internal fun anyToJsonElement(value: Any?): JsonElement = + when (value) { + null -> { + JsonNull + } + + is JsonElement -> { + value + } + + is Map<*, *> -> { + JsonObject( + (value as Map).mapValues { (_, v) -> anyToJsonElement(v) }, + ) + } + + is List<*> -> { + JsonArray(value.map { anyToJsonElement(it) }) + } + + is String -> { + JsonPrimitive(value) + } + + is Boolean -> { + JsonPrimitive(value) + } + + is Number -> { + JsonPrimitive(value) + } + + else -> { + JsonPrimitive(value.toString()) + } + } +} + +/** + * Production implementation of [SessionLiveStatusSource] backed by [HermesWsClient]. + */ +class HermesSessionLiveStatusSource( + private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params -> + HermesWsClient.request(method, params).await() + }, + eventsProvider: () -> Flow = { HermesWsClient.events }, + connectionStatusProvider: () -> StateFlow = { HermesWsClient.connectionStatus }, +) : SessionLiveStatusSource { + override val events: Flow by lazy { eventsProvider() } + override val connectionStatus: StateFlow by lazy { connectionStatusProvider() } + + override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? = + try { + val raw = rpcRequest(WsMethods.SESSION_ACTIVE_LIST, emptyMap()) + SessionLiveStatusDecoder.decodeSnapshot(raw) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducer.kt new file mode 100644 index 00000000..0fa4223b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducer.kt @@ -0,0 +1,156 @@ +package com.m57.hermescontrol.ui.sessions + +import com.m57.hermescontrol.data.model.LiveSessionSnapshot +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.data.ws.WsEvent + +/** + * Transient state for tracking live active sessions in History. + */ +data class SessionLiveTrackingState( + val liveStatuses: Map = emptyMap(), + val storedIdByRuntimeId: Map = emptyMap(), +) + +/** + * Pure reducer managing live active session transitions. + */ +object SessionLiveStatusReducer { + fun applySnapshot( + state: SessionLiveTrackingState, + snapshot: LiveSessionSnapshot, + ): SessionLiveTrackingState = + SessionLiveTrackingState( + liveStatuses = snapshot.statusByStoredId, + storedIdByRuntimeId = snapshot.storedIdByRuntimeId, + ) + + fun applyWsEvent( + state: SessionLiveTrackingState, + event: WsEvent, + ): SessionLiveTrackingState = + when (event) { + is WsEvent.SessionInfo -> reduceSessionInfo(state, event) + is WsEvent.MessageStart -> reduceMessageStart(state, event) + is WsEvent.MessageComplete -> reduceMessageComplete(state, event) + is WsEvent.MessageDone -> reduceMessageDone(state, event) + is WsEvent.ApprovalRequest -> reduceApprovalRequest(state, event) + is WsEvent.ClarifyRequest -> reduceClarifyRequest(state, event) + else -> state + } + + fun clear(): SessionLiveTrackingState = SessionLiveTrackingState() + + private fun reduceSessionInfo( + state: SessionLiveTrackingState, + event: WsEvent.SessionInfo, + ): SessionLiveTrackingState { + val data = event.data ?: return state + val storedId = (data["stored_session_id"] as? String)?.trim()?.takeIf { it.isNotEmpty() } + val runtimeId = (data["session_id"] as? String)?.trim()?.takeIf { it.isNotEmpty() } + val running = data["running"] as? Boolean + + val targetStoredId = storedId ?: runtimeId?.let { state.storedIdByRuntimeId[it] } + if (targetStoredId == null && runtimeId == null) { + return state + } + + val oldStoredId = runtimeId?.let { state.storedIdByRuntimeId[it] } + val nextMapping = + if (runtimeId != null && targetStoredId != null) { + state.storedIdByRuntimeId + (runtimeId to targetStoredId) + } else { + state.storedIdByRuntimeId + } + + if (targetStoredId == null) { + return state.copy(storedIdByRuntimeId = nextMapping) + } + + val nextStatuses = state.liveStatuses.toMutableMap() + if (oldStoredId != null && oldStoredId != targetStoredId) { + nextStatuses.remove(oldStoredId) + } + + when (running) { + true -> { + nextStatuses[targetStoredId] = SessionLiveStatus.WORKING + } + + false -> { + nextStatuses.remove(targetStoredId) + } + + null -> { + // If running is not specified, leave status untouched + } + } + + return state.copy( + liveStatuses = nextStatuses, + storedIdByRuntimeId = nextMapping, + ) + } + + private fun reduceMessageStart( + state: SessionLiveTrackingState, + event: WsEvent.MessageStart, + ): SessionLiveTrackingState { + val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state + val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state + if (state.liveStatuses[storedId] == SessionLiveStatus.WORKING) return state + return state.copy( + liveStatuses = state.liveStatuses + (storedId to SessionLiveStatus.WORKING), + ) + } + + private fun reduceMessageComplete( + state: SessionLiveTrackingState, + event: WsEvent.MessageComplete, + ): SessionLiveTrackingState { + val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } + val storedId = + event.storedSessionId?.trim()?.takeIf { it.isNotEmpty() } + ?: runtimeId?.let { state.storedIdByRuntimeId[it] } + ?: return state + + if (!state.liveStatuses.containsKey(storedId)) return state + return state.copy( + liveStatuses = state.liveStatuses - storedId, + ) + } + + private fun reduceMessageDone( + state: SessionLiveTrackingState, + event: WsEvent.MessageDone, + ): SessionLiveTrackingState { + val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state + val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state + if (!state.liveStatuses.containsKey(storedId)) return state + return state.copy( + liveStatuses = state.liveStatuses - storedId, + ) + } + + private fun reduceApprovalRequest( + state: SessionLiveTrackingState, + event: WsEvent.ApprovalRequest, + ): SessionLiveTrackingState { + val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state + val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state + return state.copy( + liveStatuses = state.liveStatuses + (storedId to SessionLiveStatus.WAITING), + ) + } + + private fun reduceClarifyRequest( + state: SessionLiveTrackingState, + event: WsEvent.ClarifyRequest, + ): SessionLiveTrackingState { + val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state + val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state + return state.copy( + liveStatuses = state.liveStatuses + (storedId to SessionLiveStatus.WAITING), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 9e5b1bbf..edfc8465 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -72,6 +72,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -274,6 +275,13 @@ fun SessionsScreen( viewModel.loadSessions() } + DisposableEffect(Unit) { + viewModel.startLiveStatusTracking() + onDispose { + viewModel.stopLiveStatusTracking() + } + } + // Toast effect ToastEffect( toastMessage = state.toastMessage, @@ -612,6 +620,7 @@ fun SessionsScreen( isSelecting = state.isSelecting, isSelected = session.id in state.selectedIds, isDeleting = session.id in state.deletingSessionIds, + liveStatus = state.liveStatuses[session.id], highlightBackground = primaryContainer, highlightForeground = onPrimaryContainer, onCardClick = { @@ -760,6 +769,7 @@ fun SessionsScreen( isDeleting = session.id in state.deletingSessionIds, isPinned = session.pinned == true, isHidden = session.hidden == true, + liveStatus = state.liveStatuses[session.id], highlightBackground = primaryContainer, highlightForeground = onPrimaryContainer, onCardClick = { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 11f0d107..6b053b09 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -5,12 +5,18 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.BulkDeleteRequest import com.m57.hermescontrol.data.model.PruneRequest import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionLiveStatus import com.m57.hermescontrol.data.model.SessionRenameRequest import com.m57.hermescontrol.data.model.SessionSearchResult import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.ws.ChangeEventHub import com.m57.hermescontrol.data.ws.ChangeEvents +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesSessionLiveStatusSource +import com.m57.hermescontrol.data.ws.SessionLiveStatusSource +import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.refreshOnChange import com.m57.hermescontrol.ui.common.safeLaunchLoad @@ -19,6 +25,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.Locale @@ -90,6 +97,7 @@ data class SessionsUiState( val searchError: String? = null, val showHidden: Boolean = false, val pinnedExpanded: Boolean = true, + val liveStatuses: Map = emptyMap(), ) { val isSearchMode: Boolean get() = searchQuery.isNotBlank() @@ -103,8 +111,9 @@ data class SessionsUiState( get() = displaySessions.filter { it.pinned == true } } -class SessionsViewModel : - ViewModel(), +class SessionsViewModel( + private val liveStatusSource: SessionLiveStatusSource = HermesSessionLiveStatusSource(), +) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(SessionsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -112,6 +121,11 @@ class SessionsViewModel : private var loadJob: Job? = null private var pageJob: Job? = null private var statsJob: Job? = null + private var trackingJob: Job? = null + private var trackingGeneration: Long = 0 + private var liveTrackingState = SessionLiveTrackingState() + private var liveStatusRefreshInFlight = false + private var liveStatusRefreshPending = false private var generation: Long = 0 private var rawPaginationOffset: Int = 0 @@ -172,6 +186,7 @@ class SessionsViewModel : const val SEARCH_DEBOUNCE_MS = 300L const val MAX_STITCH_PER_PAGE = 8 const val MAX_STITCH_ROUNDS = 3 + const val LIVE_STATUS_POLL_INTERVAL_MS = 30_000L } private val resolvedParentCache = mutableMapOf() @@ -259,6 +274,9 @@ class SessionsViewModel : val section = _uiState.value.section pageJob?.cancel() loadEmptyCount() + if (trackingJob?.isActive == true) { + refreshLiveStatuses() + } loadJob = safeLaunchLoad( currentJob = loadJob, @@ -863,4 +881,106 @@ class SessionsViewModel : override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } + + // ── Live Session Status Tracking (issue #1101) ─────────────────────── + + fun startLiveStatusTracking() { + if (trackingJob?.isActive == true) return + trackingJob = + viewModelScope.launch { + // Connection status observer: non-connected clears immediately, CONNECTED rehydrates. + launch { + liveStatusSource.connectionStatus.collect { status -> + if (status != ConnectionStatus.CONNECTED) { + trackingGeneration++ + liveTrackingState = SessionLiveStatusReducer.clear() + _uiState.update { it.copy(liveStatuses = emptyMap()) } + } else { + requestLiveStatusSnapshot(++trackingGeneration) + } + } + } + + // Live WebSocket events observer. + launch { + liveStatusSource.events.collect { event -> + liveTrackingState = SessionLiveStatusReducer.applyWsEvent(liveTrackingState, event) + _uiState.update { it.copy(liveStatuses = liveTrackingState.liveStatuses) } + } + } + + // Change events observer: sessions.changed triggers a snapshot refresh. + launch { + ChangeEventHub.events + .filter { it.type == ChangeEvents.SESSIONS } + .collect { + requestLiveStatusSnapshot(trackingGeneration) + } + } + + // Visible-only 30s poll backstop + launch { + while (true) { + delay(LIVE_STATUS_POLL_INTERVAL_MS) + requestLiveStatusSnapshot(trackingGeneration) + } + } + } + } + + fun stopLiveStatusTracking() { + trackingGeneration++ + trackingJob?.cancel() + trackingJob = null + liveStatusRefreshInFlight = false + liveStatusRefreshPending = false + liveTrackingState = SessionLiveStatusReducer.clear() + _uiState.update { it.copy(liveStatuses = emptyMap()) } + } + + fun refreshLiveStatuses() { + requestLiveStatusSnapshot(trackingGeneration) + } + + private fun requestLiveStatusSnapshot(gen: Long) { + if (trackingJob?.isActive != true || + gen != trackingGeneration || + liveStatusSource.connectionStatus.value != ConnectionStatus.CONNECTED + ) { + return + } + if (liveStatusRefreshInFlight) { + liveStatusRefreshPending = true + return + } + liveStatusRefreshInFlight = true + viewModelScope.launch { + try { + val snapshot = liveStatusSource.fetchActiveSessionsSnapshot() + if (trackingJob?.isActive == true && + gen == trackingGeneration && + liveStatusSource.connectionStatus.value == ConnectionStatus.CONNECTED && + snapshot != null + ) { + liveTrackingState = SessionLiveStatusReducer.applySnapshot(liveTrackingState, snapshot) + _uiState.update { it.copy(liveStatuses = liveTrackingState.liveStatuses) } + } + } finally { + liveStatusRefreshInFlight = false + if (liveStatusRefreshPending && + trackingJob?.isActive == true && + gen == trackingGeneration && + liveStatusSource.connectionStatus.value == ConnectionStatus.CONNECTED + ) { + liveStatusRefreshPending = false + requestLiveStatusSnapshot(gen) + } + } + } + } + + override fun onCleared() { + super.onCleared() + stopLiveStatusTracking() + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt index 054703d4..b5806b9c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt @@ -37,6 +37,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType @@ -56,6 +58,7 @@ fun SearchResultCard( isSelecting: Boolean, isSelected: Boolean, isDeleting: Boolean, + liveStatus: SessionLiveStatus? = null, highlightBackground: Color, highlightForeground: Color, onCardClick: () -> Unit, @@ -65,6 +68,7 @@ fun SearchResultCard( onDelete: () -> Unit, ) { val spacing = LocalSpacing.current + val statusColors = LocalHermesStatusColors.current val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) val cleanSnippet = cleanSearchSnippet(snippet) val playedAt = formatPlayedAt(session.started_at) @@ -86,6 +90,10 @@ fun SearchResultCard( border = if (isSelected) { BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else if (!isSelecting && liveStatus == SessionLiveStatus.WORKING) { + BorderStroke(2.dp, statusColors.success) + } else if (!isSelecting && liveStatus == SessionLiveStatus.WAITING) { + BorderStroke(2.dp, statusColors.warning) } else { null }, @@ -153,6 +161,12 @@ fun SearchResultCard( horizontalArrangement = Arrangement.spacedBy(spacing.xs), verticalArrangement = Arrangement.spacedBy(spacing.xs), ) { + if (liveStatus != null) { + SessionLiveStatusIndicator( + liveStatus = liveStatus, + sessionId = session.id, + ) + } session.source?.let { src -> StatusBadge( text = sourceLabel(src), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt index 33b5ea7d..108de012 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionLiveStatus import com.m57.hermescontrol.data.model.SessionTreeItem import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing @@ -146,6 +147,7 @@ fun SessionCard( isDeleting: Boolean, isPinned: Boolean, isHidden: Boolean = false, + liveStatus: SessionLiveStatus? = null, highlightBackground: Color, highlightForeground: Color, onCardClick: () -> Unit, @@ -158,7 +160,6 @@ fun SessionCard( ) { val spacing = LocalSpacing.current val statusColors = LocalHermesStatusColors.current - val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming" val srcIcon = sourceIcon(session.source) var menuExpanded by remember { mutableStateOf(false) } @@ -176,10 +177,12 @@ fun SessionCard( containerColor = MaterialTheme.colorScheme.surfaceContainer, ), border = - if (isActive && !isSelecting) { - BorderStroke(2.dp, statusColors.success) - } else if (isSelected) { + if (isSelected) { BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else if (!isSelecting && liveStatus == SessionLiveStatus.WORKING) { + BorderStroke(2.dp, statusColors.success) + } else if (!isSelecting && liveStatus == SessionLiveStatus.WAITING) { + BorderStroke(2.dp, statusColors.warning) } else { null }, @@ -270,11 +273,11 @@ fun SessionCard( status = StatusBadgeType.NEUTRAL, ) } - if (!session.status.isNullOrBlank()) { + if (liveStatus != null) { Spacer(modifier = Modifier.width(spacing.sm)) - StatusBadge( - text = session.status, - status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL, + SessionLiveStatusIndicator( + liveStatus = liveStatus, + sessionId = session.id, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt new file mode 100644 index 00000000..112493f2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt @@ -0,0 +1,112 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.theme.LocalSpacing + +@Composable +fun SessionLiveStatusIndicator( + liveStatus: SessionLiveStatus, + sessionId: String, + modifier: Modifier = Modifier, +) { + val spacing = LocalSpacing.current + val statusColors = LocalHermesStatusColors.current + + val (bgColor, fgColor, textRes, descRes) = + when (liveStatus) { + SessionLiveStatus.WORKING -> { + Tuple4( + statusColors.successContainer, + statusColors.success, + R.string.sessions_live_status_running, + R.string.sessions_live_status_running_desc, + ) + } + + SessionLiveStatus.WAITING -> { + Tuple4( + statusColors.warningContainer, + statusColors.warning, + R.string.sessions_live_status_waiting, + R.string.sessions_live_status_waiting_desc, + ) + } + } + + val text = stringResource(textRes) + val description = stringResource(descRes) + + Surface( + modifier = + modifier + .testTag("session_live_status_$sessionId") + .semantics { contentDescription = description }, + shape = RoundedCornerShape(8.dp), + color = bgColor, + ) { + Row( + modifier = + Modifier.padding( + horizontal = spacing.sm, + vertical = 2.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + when (liveStatus) { + SessionLiveStatus.WORKING -> { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = fgColor, + ) + } + + SessionLiveStatus.WAITING -> { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = fgColor, + modifier = Modifier.size(12.dp), + ) + } + } + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = fgColor, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +private data class Tuple4( + val first: A, + val second: B, + val third: C, + val fourth: D, +) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8e05c1b1..1fabe321 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -543,6 +543,10 @@ جميع الجلسات مخفية حاليًا. اضغط أدناه لإظهار الجلسات المخفية. مثبتة جلسة مفرعة +قيد التشغيل +بانتظار إدخال +الجلسة قيد التشغيل +الجلسة بانتظار إدخال من المستخدم إعادة تسمية الجلسة عنوان الجلسة لم يتم العثور على مجموعات أدوات diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 39b4ad51..631c47b6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -423,6 +423,10 @@ 숨겨진 세션 숨기기 숨김 모든 세션이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 세션을 표시하세요. + 실행 중 + 입력 대기 중 + 세션이 실행 중입니다 + 세션이 사용자 입력을 대기하고 있습니다 툴셋이 없습니다 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index a6c25201..3745a59c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -534,6 +534,10 @@ 所有会话当前均已隐藏。点击下方以显示已隐藏的会话。 已置顶 分支会话 + 运行中 + 等待输入 + 会话正在运行中 + 会话等待用户输入 重命名会话 会话标题 没有工具集数据 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21d1b61d..8b4897ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -619,6 +619,10 @@ All sessions are currently hidden. Tap below to show hidden sessions. Pinned Forked session + Running + Needs input + Session is running + Session needs user input Rename Session Session title diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt new file mode 100644 index 00000000..4a32ef87 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt @@ -0,0 +1,168 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.LiveSessionSnapshot +import com.m57.hermescontrol.data.model.SessionLiveStatus +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionLiveStatusSourceTest { + @Test + fun `decodeSnapshot decodes working and waiting statuses by stored id`() { + val payload = + mapOf( + "sessions" to + listOf( + mapOf( + "id" to "runtime-1", + "session_key" to "stored-1", + "status" to "working", + ), + mapOf( + "id" to "runtime-2", + "session_key" to "stored-2", + "status" to "waiting", + ), + ), + ) + + val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload) + + assertNotNull(snapshot) + assertEquals(SessionLiveStatus.WORKING, snapshot!!.statusByStoredId["stored-1"]) + assertEquals(SessionLiveStatus.WAITING, snapshot.statusByStoredId["stored-2"]) + assertEquals("stored-1", snapshot.storedIdByRuntimeId["runtime-1"]) + assertEquals("stored-2", snapshot.storedIdByRuntimeId["runtime-2"]) + } + + @Test + fun `decodeSnapshot ignores idle and starting for status but keeps runtime mapping`() { + val payload = + mapOf( + "sessions" to + listOf( + mapOf( + "id" to "runtime-idle", + "session_key" to "stored-idle", + "status" to "idle", + ), + mapOf( + "id" to "runtime-start", + "session_key" to "stored-start", + "status" to "starting", + ), + ), + ) + + val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload) + + assertNotNull(snapshot) + assertTrue(snapshot!!.statusByStoredId.isEmpty()) + assertEquals("stored-idle", snapshot.storedIdByRuntimeId["runtime-idle"]) + assertEquals("stored-start", snapshot.storedIdByRuntimeId["runtime-start"]) + } + + @Test + fun `decodeSnapshot skips items with blank id or session_key`() { + val payload = + mapOf( + "sessions" to + listOf( + mapOf( + "id" to "", + "session_key" to "stored-1", + "status" to "working", + ), + mapOf( + "id" to "runtime-2", + "session_key" to " ", + "status" to "working", + ), + mapOf( + "id" to "runtime-3", + "session_key" to "stored-3", + "status" to "working", + ), + ), + ) + + val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload) + + assertNotNull(snapshot) + assertEquals(1, snapshot!!.statusByStoredId.size) + assertEquals(SessionLiveStatus.WORKING, snapshot.statusByStoredId["stored-3"]) + assertEquals(1, snapshot.storedIdByRuntimeId.size) + assertEquals("stored-3", snapshot.storedIdByRuntimeId["runtime-3"]) + } + + @Test + fun `decodeSnapshot returns authoritative empty snapshot on empty sessions list`() { + val payload = mapOf("sessions" to emptyList>()) + + val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload) + + assertNotNull(snapshot) + assertTrue(snapshot!!.statusByStoredId.isEmpty()) + assertTrue(snapshot.storedIdByRuntimeId.isEmpty()) + } + + @Test + fun `decodeSnapshot returns null for malformed or missing sessions field`() { + assertNull(SessionLiveStatusDecoder.decodeSnapshot(null)) + assertNull(SessionLiveStatusDecoder.decodeSnapshot(mapOf("other" to "data"))) + assertNull(SessionLiveStatusDecoder.decodeSnapshot("invalid-json-string")) + } + + @Test + fun `HermesSessionLiveStatusSource fetches and decodes snapshot via RPC`() = + runBlocking { + var capturedMethod: String? = null + var capturedParams: Map? = null + + val source = + HermesSessionLiveStatusSource( + rpcRequest = { method, params -> + capturedMethod = method + capturedParams = params + mapOf( + "sessions" to + listOf( + mapOf( + "id" to "rt-1", + "session_key" to "st-1", + "status" to "working", + ), + ), + ) + }, + eventsProvider = { MutableSharedFlow() }, + connectionStatusProvider = { MutableStateFlow(ConnectionStatus.CONNECTED) }, + ) + + val snapshot = source.fetchActiveSessionsSnapshot() + + assertEquals(WsMethods.SESSION_ACTIVE_LIST, capturedMethod) + assertTrue(capturedParams!!.isEmpty()) + assertNotNull(snapshot) + assertEquals(SessionLiveStatus.WORKING, snapshot!!.statusByStoredId["st-1"]) + } + + @Test + fun `HermesSessionLiveStatusSource returns null when RPC throws`() = + runBlocking { + val source = + HermesSessionLiveStatusSource( + rpcRequest = { _, _ -> throw RuntimeException("RPC error") }, + eventsProvider = { MutableSharedFlow() }, + connectionStatusProvider = { MutableStateFlow(ConnectionStatus.CONNECTED) }, + ) + + val snapshot = source.fetchActiveSessionsSnapshot() + assertNull(snapshot) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducerTest.kt new file mode 100644 index 00000000..3f4528bc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducerTest.kt @@ -0,0 +1,239 @@ +package com.m57.hermescontrol.ui.sessions + +import com.m57.hermescontrol.data.model.LiveSessionSnapshot +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.data.ws.WsEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionLiveStatusReducerTest { + @Test + fun `snapshot replacement seeds WORKING and WAITING by stored ID`() { + val snapshot = + LiveSessionSnapshot( + statusByStoredId = + mapOf( + "stored-1" to SessionLiveStatus.WORKING, + "stored-2" to SessionLiveStatus.WAITING, + ), + storedIdByRuntimeId = + mapOf( + "rt-1" to "stored-1", + "rt-2" to "stored-2", + ), + ) + + val state = SessionLiveStatusReducer.applySnapshot(SessionLiveTrackingState(), snapshot) + + assertEquals(SessionLiveStatus.WORKING, state.liveStatuses["stored-1"]) + assertEquals(SessionLiveStatus.WAITING, state.liveStatuses["stored-2"]) + assertEquals("stored-1", state.storedIdByRuntimeId["rt-1"]) + assertEquals("stored-2", state.storedIdByRuntimeId["rt-2"]) + } + + @Test + fun `later successful empty snapshot removes previously live rows`() { + val initial = + SessionLiveTrackingState( + liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val state = + SessionLiveStatusReducer.applySnapshot( + initial, + LiveSessionSnapshot(emptyMap(), emptyMap()), + ) + + assertTrue(state.liveStatuses.isEmpty()) + assertTrue(state.storedIdByRuntimeId.isEmpty()) + } + + @Test + fun `runtime ID reuse moves activity from old stored ID to new stored ID`() { + val initial = + SessionLiveTrackingState( + liveStatuses = mapOf("stored-old" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-old"), + ) + + val event = + WsEvent.SessionInfo( + data = + mapOf( + "session_id" to "rt-1", + "stored_session_id" to "stored-new", + "running" to true, + ), + ) + + val state = SessionLiveStatusReducer.applyWsEvent(initial, event) + + assertNull(state.liveStatuses["stored-old"]) + assertEquals(SessionLiveStatus.WORKING, state.liveStatuses["stored-new"]) + assertEquals("stored-new", state.storedIdByRuntimeId["rt-1"]) + } + + @Test + fun `session info with stored_session_id and running=true marks WORKING`() { + val initial = SessionLiveTrackingState() + + val event = + WsEvent.SessionInfo( + data = + mapOf( + "session_id" to "rt-1", + "stored_session_id" to "stored-1", + "running" to true, + ), + ) + + val state = SessionLiveStatusReducer.applyWsEvent(initial, event) + + assertEquals(SessionLiveStatus.WORKING, state.liveStatuses["stored-1"]) + assertEquals("stored-1", state.storedIdByRuntimeId["rt-1"]) + } + + @Test + fun `session info with running=false clears that stored row`() { + val initial = + SessionLiveTrackingState( + liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val event = + WsEvent.SessionInfo( + data = + mapOf( + "session_id" to "rt-1", + "stored_session_id" to "stored-1", + "running" to false, + ), + ) + + val state = SessionLiveStatusReducer.applyWsEvent(initial, event) + + assertNull(state.liveStatuses["stored-1"]) + assertEquals("stored-1", state.storedIdByRuntimeId["rt-1"]) + } + + @Test + fun `message start marks WORKING only when runtime-to-stored identity is known`() { + val initial = + SessionLiveTrackingState( + liveStatuses = emptyMap(), + storedIdByRuntimeId = mapOf("rt-known" to "stored-known"), + ) + + // Unknown runtime id -> no mutation + val unknownEvent = WsEvent.MessageStart(sessionId = "rt-unknown") + val state1 = SessionLiveStatusReducer.applyWsEvent(initial, unknownEvent) + assertTrue(state1.liveStatuses.isEmpty()) + + // Known runtime id -> marks WORKING + val knownEvent = WsEvent.MessageStart(sessionId = "rt-known") + val state2 = SessionLiveStatusReducer.applyWsEvent(state1, knownEvent) + assertEquals(SessionLiveStatus.WORKING, state2.liveStatuses["stored-known"]) + } + + @Test + fun `message complete and message done clear only matching runtime`() { + val initial = + SessionLiveTrackingState( + liveStatuses = + mapOf( + "stored-1" to SessionLiveStatus.WORKING, + "stored-2" to SessionLiveStatus.WORKING, + ), + storedIdByRuntimeId = + mapOf( + "rt-1" to "stored-1", + "rt-2" to "stored-2", + ), + ) + + val completeEvent = WsEvent.MessageComplete(text = "done", sessionId = "rt-1") + val state1 = SessionLiveStatusReducer.applyWsEvent(initial, completeEvent) + + assertNull(state1.liveStatuses["stored-1"]) + assertEquals(SessionLiveStatus.WORKING, state1.liveStatuses["stored-2"]) + + val doneEvent = WsEvent.MessageDone(sessionId = "rt-2") + val state2 = SessionLiveStatusReducer.applyWsEvent(state1, doneEvent) + + assertNull(state2.liveStatuses["stored-2"]) + assertTrue(state2.liveStatuses.isEmpty()) + } + + @Test + fun `approval request and clarify request mark WAITING for known runtime`() { + val initial = + SessionLiveTrackingState( + liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val approvalEvent = + WsEvent.ApprovalRequest( + command = "rm -rf", + description = "delete", + patternKeys = null, + sessionId = "rt-1", + requestId = "req-1", + choices = listOf("allow", "deny"), + allowPermanent = false, + smartDenied = false, + ) + + val state = SessionLiveStatusReducer.applyWsEvent(initial, approvalEvent) + assertEquals(SessionLiveStatus.WAITING, state.liveStatuses["stored-1"]) + + val clarifyEvent = + WsEvent.ClarifyRequest( + text = "which option?", + options = listOf("a", "b"), + sessionId = "rt-1", + ) + + val state2 = SessionLiveStatusReducer.applyWsEvent(initial, clarifyEvent) + assertEquals(SessionLiveStatus.WAITING, state2.liveStatuses["stored-1"]) + } + + @Test + fun `unknown or missing IDs never mutate another session`() { + val initial = + SessionLiveTrackingState( + liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val state1 = SessionLiveStatusReducer.applyWsEvent(initial, WsEvent.MessageStart(sessionId = null)) + assertEquals(initial, state1) + + val state2 = SessionLiveStatusReducer.applyWsEvent(initial, WsEvent.MessageDone(sessionId = "other")) + assertEquals(initial, state2) + + val state3 = + SessionLiveStatusReducer.applyWsEvent( + initial, + WsEvent.SessionInfo(data = null), + ) + assertEquals(initial, state3) + } + + @Test + fun `clear resets state to empty`() { + val initial = + SessionLiveTrackingState( + liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val cleared = SessionLiveStatusReducer.clear() + assertTrue(cleared.liveStatuses.isEmpty()) + assertTrue(cleared.storedIdByRuntimeId.isEmpty()) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index cc4bcb60..66d7b0c1 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -1,19 +1,31 @@ package com.m57.hermescontrol.ui.sessions +import com.m57.hermescontrol.data.model.LiveSessionSnapshot import com.m57.hermescontrol.data.model.SessionInfo import com.m57.hermescontrol.data.model.SessionListResponse +import com.m57.hermescontrol.data.model.SessionLiveStatus import com.m57.hermescontrol.data.model.SessionSearchResponse import com.m57.hermescontrol.data.model.SessionSearchResult import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.ws.ChangeEventHub +import com.m57.hermescontrol.data.ws.ChangeEvents +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.SessionLiveStatusSource +import com.m57.hermescontrol.data.ws.WsEvent import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle @@ -24,6 +36,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -34,8 +47,25 @@ class SessionsViewModelTest { private val testDispatcher = StandardTestDispatcher() private val mockApi = mockk(relaxed = true) - private fun createViewModel(): SessionsViewModel { - val vm = SessionsViewModel() + private class FakeSessionLiveStatusSource : SessionLiveStatusSource { + val eventsFlow = MutableSharedFlow(extraBufferCapacity = 16) + val connectionStatusFlow = MutableStateFlow(ConnectionStatus.CONNECTED) + var snapshotToReturn: LiveSessionSnapshot? = null + var fetchCallCount = 0 + var snapshotDeferred: CompletableDeferred? = null + + override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? { + fetchCallCount++ + val deferred = snapshotDeferred + return if (deferred != null) deferred.await() else snapshotToReturn + } + + override val events: Flow = eventsFlow + override val connectionStatus: StateFlow = connectionStatusFlow + } + + private fun createViewModel(source: SessionLiveStatusSource = FakeSessionLiveStatusSource()): SessionsViewModel { + val vm = SessionsViewModel(liveStatusSource = source) testDispatcher.scheduler.advanceUntilIdle() return vm } @@ -548,4 +578,219 @@ class SessionsViewModelTest { vm.togglePinnedExpanded() assertTrue(vm.uiState.value.pinnedExpanded) } + + // ── Live Session Status Tracking (issue #1101) ─────────────────────── + + @Test + fun `initial snapshot hydration updates liveStatuses`() { + val source = FakeSessionLiveStatusSource() + source.snapshotToReturn = + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + + assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"]) + assertEquals(1, source.fetchCallCount) + vm.stopLiveStatusTracking() + } + + @Test + fun `successful absence reaping clears statuses not in snapshot`() { + val source = FakeSessionLiveStatusSource() + source.snapshotToReturn = + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"]) + + // Second snapshot returns empty + source.snapshotToReturn = LiveSessionSnapshot(emptyMap(), emptyMap()) + vm.refreshLiveStatuses() + testDispatcher.scheduler.runCurrent() + + assertTrue( + vm.uiState.value.liveStatuses + .isEmpty(), + ) + assertEquals(2, source.fetchCallCount) + vm.stopLiveStatusTracking() + } + + @Test + fun `working to idle event transition via SessionInfo updates liveStatuses`() { + val source = FakeSessionLiveStatusSource() + source.snapshotToReturn = + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + + assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"]) + + // Send running = false + source.eventsFlow.tryEmit( + WsEvent.SessionInfo( + data = + mapOf( + "session_id" to "rt-1", + "stored_session_id" to "stored-1", + "running" to false, + ), + ), + ) + testDispatcher.scheduler.runCurrent() + + assertNull(vm.uiState.value.liveStatuses["stored-1"]) + vm.stopLiveStatusTracking() + } + + @Test + fun `disconnect clears immediately and reconnect rehydrates`() { + val source = FakeSessionLiveStatusSource() + source.snapshotToReturn = + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"]) + + // Disconnect + source.connectionStatusFlow.value = ConnectionStatus.DISCONNECTED + testDispatcher.scheduler.runCurrent() + assertTrue( + vm.uiState.value.liveStatuses + .isEmpty(), + ) + + // Reconnect + source.snapshotToReturn = + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WAITING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + source.connectionStatusFlow.value = ConnectionStatus.CONNECTED + testDispatcher.scheduler.runCurrent() + + assertEquals(SessionLiveStatus.WAITING, vm.uiState.value.liveStatuses["stored-1"]) + vm.stopLiveStatusTracking() + } + + @Test + fun `late pre-disconnect response is ignored`() { + val source = FakeSessionLiveStatusSource() + val deferred = CompletableDeferred() + source.snapshotDeferred = deferred + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + + // Disconnect before deferred completes + source.connectionStatusFlow.value = ConnectionStatus.DISCONNECTED + testDispatcher.scheduler.runCurrent() + + // Complete the late response + deferred.complete( + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ), + ) + testDispatcher.scheduler.runCurrent() + + // Must remain empty because the response was from the previous generation + assertTrue( + vm.uiState.value.liveStatuses + .isEmpty(), + ) + vm.stopLiveStatusTracking() + } + + @Test + fun `repeated start is idempotent`() { + val source = FakeSessionLiveStatusSource() + source.snapshotToReturn = LiveSessionSnapshot() + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + + assertEquals(1, source.fetchCallCount) + vm.stopLiveStatusTracking() + } + + @Test + fun `stop cancels ticker and clears state`() { + val source = FakeSessionLiveStatusSource() + source.snapshotToReturn = + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + assertEquals(1, vm.uiState.value.liveStatuses.size) + + vm.stopLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + assertTrue( + vm.uiState.value.liveStatuses + .isEmpty(), + ) + + // Advancing time past the 30s poll interval should NOT trigger another fetch + testDispatcher.scheduler.advanceTimeBy(60_000) + testDispatcher.scheduler.runCurrent() + assertEquals(1, source.fetchCallCount) + } + + @Test + fun `change bursts coalesce into at most one trailing request`() { + val source = FakeSessionLiveStatusSource() + val deferred1 = CompletableDeferred() + source.snapshotDeferred = deferred1 + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + assertEquals(1, source.fetchCallCount) + + // Multiple change events while request 1 is in flight + source.snapshotDeferred = CompletableDeferred() + ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS, null)) + ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS, null)) + testDispatcher.scheduler.runCurrent() + + // Call count should still be 1 (in-flight) + assertEquals(1, source.fetchCallCount) + + // Complete request 1 + deferred1.complete(LiveSessionSnapshot()) + testDispatcher.scheduler.runCurrent() + + // Request 2 was coalesced and launched as the trailing request + assertEquals(2, source.fetchCallCount) + vm.stopLiveStatusTracking() + } } From 4246ae196d99a427a52eb967758a8f2cb36cb3a0 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:45:04 +0300 Subject: [PATCH 40/42] feat(settings): restore last session on startup (#1102) (#1105) --- .../data/config/ServerStoreState.kt | 2 + .../hermescontrol/data/local/AuthManager.kt | 41 ++++++++++ .../hermescontrol/ui/chat/ChatViewModel.kt | 12 +++ .../ui/sessions/SessionsViewModel.kt | 8 ++ .../ui/settings/SettingsScreen.kt | 2 +- .../ui/settings/SettingsSubPages.kt | 2 + .../ui/settings/SettingsViewModel.kt | 11 +++ .../ui/settings/components/BehaviorSection.kt | 43 ++++++++++- app/src/main/res/values-ar/strings.xml | 2 + app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values-zh/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../ui/chat/ChatUpdateCommandTest.kt | 4 + .../ui/chat/ChatViewModelTest.kt | 75 +++++++++++++++++++ .../ui/chat/SlashCommandDispatchRpcTest.kt | 4 + .../ui/settings/SettingsViewModelTest.kt | 26 +++++++ 16 files changed, 234 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 2139218c..1112d746 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -12,6 +12,8 @@ data class ServerStoreState( val port: Int = 9119, val baseUrl: String? = null, val autoReconnect: Boolean = true, + val restoreLastSession: Boolean = false, + val lastOpenedSessionIds: Map = emptyMap(), val themePreference: ThemePreference = ThemePreference.SYSTEM, val useDynamicColors: Boolean = false, val themePreset: ThemePreset = ThemePreset.DEFAULT, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 33a15a4f..2cf351ff 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -616,6 +616,47 @@ object AuthManager { serverStore.update { it.copy(autoReconnect = enabled) } } + // ── Restore last session on startup (issue #1102) ───────────────────── + + private fun currentSessionScopeKey(): String { + val connId = getSelectedProfileId()?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID + val serverProfile = activeProfileId.value?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID + return "$connId:$serverProfile" + } + + fun isRestoreLastSession(): Boolean = serverStore.getLatestState().restoreLastSession + + fun setRestoreLastSession(enabled: Boolean) { + serverStore.update { it.copy(restoreLastSession = enabled) } + } + + fun getLastOpenedSessionId(): String? = serverStore.getLatestState().lastOpenedSessionIds[currentSessionScopeKey()] + + fun setLastOpenedSessionId(sessionId: String?) { + val key = currentSessionScopeKey() + serverStore.update { state -> + val updated = + if (sessionId != null) { + state.lastOpenedSessionIds + (key to sessionId) + } else { + state.lastOpenedSessionIds - key + } + state.copy(lastOpenedSessionIds = updated) + } + } + + fun clearLastOpenedSessionId() { + setLastOpenedSessionId(null) + } + + fun clearLastOpenedSessionIdsForConnection(connectionProfileId: String) { + serverStore.update { state -> + val prefix = "$connectionProfileId:" + val updated = state.lastOpenedSessionIds.filterKeys { !it.startsWith(prefix) } + state.copy(lastOpenedSessionIds = updated) + } + } + // ── Theme preference ────────────────────────────────────────────────── fun getThemePreference(): ThemePreference = serverStore.getLatestState().themePreference diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 52438642..1c23f3b8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -677,6 +677,13 @@ class ChatViewModel( if (!initial.isNullOrBlank()) { initialSessionId = null switchSession(initial) + } else if (AuthManager.isRestoreLastSession()) { + val restoredId = AuthManager.getLastOpenedSessionId() + if (!restoredId.isNullOrBlank()) { + switchSession(restoredId) + } else { + createNewSession(setLoading = false) + } } else { createNewSession(setLoading = false) } @@ -1406,6 +1413,7 @@ class ChatViewModel( userMessage: ChatMessage? = null, ) { val dispatchGeneration = sessionGeneration + AuthManager.setLastOpenedSessionId(storageSessionId) val msgToPersist = userMessage ?: ChatMessage( role = MessageRole.USER, @@ -2356,6 +2364,7 @@ class ChatViewModel( .find { it.id == sessionId } ?.title ?: "Hermes" val generation = resetSessionState(sessionId, title, isLoading = true) + AuthManager.setLastOpenedSessionId(sessionId) viewModelScope.launch { // Warm-cache fast-path (desktop parity): paint the cached Room // transcript immediately so the screen never sits blank, then load @@ -2708,6 +2717,9 @@ class ChatViewModel( } sessionGoneRecoveryInFlight = true cancelResumeRetry() + if (AuthManager.getLastOpenedSessionId() == sessionId) { + AuthManager.clearLastOpenedSessionId() + } _uiState.update { it.copy( isLoading = false, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 6b053b09..bf8b0135 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.sessions import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.BulkDeleteRequest import com.m57.hermescontrol.data.model.PruneRequest import com.m57.hermescontrol.data.model.SessionInfo @@ -689,6 +690,9 @@ class SessionsViewModel( } when (result) { is NetworkResult.Success -> { + if (AuthManager.getLastOpenedSessionId() == sessionId) { + AuthManager.clearLastOpenedSessionId() + } _uiState.update { val updatedSessions = it.sessions.filter { s -> s.id != sessionId } val newTotal = (it.total - 1).coerceAtLeast(0) @@ -739,6 +743,10 @@ class SessionsViewModel( } when (result) { is NetworkResult.Success -> { + val lastOpened = AuthManager.getLastOpenedSessionId() + if (lastOpened != null && ids.contains(lastOpened)) { + AuthManager.clearLastOpenedSessionId() + } val deletedCount = result.data.deleted val toastMsg = if (deletedCount > 0) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 67d8d42b..071af035 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -113,7 +113,7 @@ fun SettingsScreen( icon = Icons.Filled.Tune, label = stringResource(R.string.settings_sec_behavior), summary = - if (state.autoReconnect) { + if (state.autoReconnect || state.restoreLastSession) { stringResource(R.string.settings_summary_on) } else { stringResource(R.string.settings_summary_off) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index db5a0e92..561072be 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -227,6 +227,8 @@ internal fun SettingsBehaviorPage( BehaviorSection( autoReconnect = state.autoReconnect, onAutoReconnectChange = viewModel::onAutoReconnectChange, + restoreLastSession = state.restoreLastSession, + onRestoreLastSessionChange = viewModel::onRestoreLastSessionChange, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 20dd7f6b..594da4d2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -28,6 +28,7 @@ data class SettingsUiState( val transportWarning: String? = null, val token: String = "", val autoReconnect: Boolean = true, + val restoreLastSession: Boolean = false, val themePreference: ThemePreference = ThemePreference.SYSTEM, val useDynamicColors: Boolean = false, val themePreset: ThemePreset = ThemePreset.DEFAULT, @@ -72,6 +73,7 @@ class SettingsViewModel( val baseUrl = AuthManager.getBaseUrl() val token = AuthManager.getToken() ?: "" val autoReconnect = AuthManager.isAutoReconnect() + val restoreLastSession = AuthManager.isRestoreLastSession() val themePreference = AuthManager.getThemePreference() val useDynamicColors = AuthManager.isUseDynamicColors() val themePreset = AuthManager.getThemePreset() @@ -92,6 +94,7 @@ class SettingsViewModel( transportWarning = transportWarning, token = token, autoReconnect = autoReconnect, + restoreLastSession = restoreLastSession, themePreference = themePreference, useDynamicColors = useDynamicColors, themePreset = themePreset, @@ -120,6 +123,7 @@ class SettingsViewModel( val updatedProfiles = AuthManager.getConnectionProfiles().filter { it.id != profileId } AuthManager.saveConnectionProfiles(updatedProfiles) AuthManager.setProfileToken(profileId, null) + AuthManager.clearLastOpenedSessionIdsForConnection(profileId) // Never leave selection null (issue #478): if the deleted profile was selected, // fall back to the default profile instead of clearing selection. val reselectDefault = @@ -283,6 +287,11 @@ class SettingsViewModel( AuthManager.setAutoReconnect(enabled) } + fun onRestoreLastSessionChange(enabled: Boolean) { + _uiState.update { it.copy(restoreLastSession = enabled, isSaved = false) } + AuthManager.setRestoreLastSession(enabled) + } + fun onThemeChange(theme: ThemePreference) { _uiState.update { it.copy(themePreference = theme, isSaved = false) } AuthManager.setThemePreference(theme) @@ -323,6 +332,7 @@ class SettingsViewModel( AuthManager.setToken(null) AuthManager.setSessionCookie(null) AuthManager.setWsAuthParam("token") + AuthManager.clearLastOpenedSessionId() HermesWsClient.disconnect(clearPendingMessages = true) // Don't rebuild ApiClient here — let the navigation complete first } @@ -337,6 +347,7 @@ class SettingsViewModel( AuthManager.setBaseUrl(normalized) AuthManager.setToken(state.token) AuthManager.setAutoReconnect(state.autoReconnect) + AuthManager.setRestoreLastSession(state.restoreLastSession) // B6 (Jun 18 2026, kanban t_86e9be9b): persist theme choice so it // survives a cold start (was previously dropped on save()). AuthManager.setThemePreference(state.themePreference) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt index e9417d77..1bb9bd17 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt @@ -3,17 +3,19 @@ package com.m57.hermescontrol.ui.settings.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.ui.settings.SectionCard @@ -21,6 +23,8 @@ import com.m57.hermescontrol.ui.settings.SectionCard internal fun BehaviorSection( autoReconnect: Boolean, onAutoReconnectChange: (Boolean) -> Unit, + restoreLastSession: Boolean, + onRestoreLastSessionChange: (Boolean) -> Unit, ) { SectionCard { Row( @@ -28,7 +32,7 @@ internal fun BehaviorSection( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Column { + Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.settings_item_auto_reconnect), style = MaterialTheme.typography.bodyLarge, @@ -51,5 +55,38 @@ internal fun BehaviorSection( ), ) } + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_item_restore_session), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_desc_restore_session), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = restoreLastSession, + onCheckedChange = onRestoreLastSessionChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 1fabe321..44861de2 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -177,6 +177,8 @@ السلوك إعادة الاتصال التلقائي إعادة الاتصال تلقائيًا عند انقطاعه +استعادة الجلسة الأخيرة +إعادة فتح أحدث جلسة محادثة عند بدء التشغيل المظهر السمة المحادثة diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 631c47b6..32266616 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -180,6 +180,8 @@ 동작 자동 재연결 연결 끊김 시 자동 재연결 + 마지막 세션 복원 + 시작 시 가장 최근 채팅 세션을 다시 엽니다 테마 테마 채팅 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 3745a59c..af463661 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -177,6 +177,8 @@ 行为 自动重连 断开后自动重新连接 + 恢复上次会话 + 启动时重新打开最近的聊天会话 外观 主题 对话 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8b4897ef..1da97682 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -201,6 +201,8 @@ Behavior Auto-Reconnect Automatically reconnect on disconnect + Restore Last Session + Reopen the most recent chat session on startup Appearance Theme Chat diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt index 437cbe3a..da758fe5 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt @@ -90,6 +90,10 @@ class ChatUpdateCommandTest { every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.isRestoreLastSession() } returns false + every { AuthManager.getLastOpenedSessionId() } returns null + every { AuthManager.setLastOpenedSessionId(any()) } returns Unit + every { AuthManager.clearLastOpenedSessionId() } returns Unit every { HermesWsClient.events } returns mockEventsFlow every { HermesWsClient.connectionStatus } returns mockConnectionStatus every { HermesWsClient.connect() } answers { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index ca845d81..1803c960 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -164,6 +164,10 @@ class ChatViewModelTest { every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.isRestoreLastSession() } returns false + every { AuthManager.getLastOpenedSessionId() } returns null + every { AuthManager.setLastOpenedSessionId(any()) } returns Unit + every { AuthManager.clearLastOpenedSessionId() } returns Unit every { HermesWsClient.events } returns mockEventsFlow every { HermesWsClient.connectionStatus } returns mockConnectionStatus every { HermesWsClient.connect() } answers { @@ -5083,4 +5087,75 @@ class ChatViewModelTest { assertTrue(updated.logs.any { it.text.contains("Stopped subagent") }) io.mockk.verify { HermesWsClient.sendRedirect(sessionId, "/stop sub-123", any()) } } + + @Test + fun testHandleGatewayReady_withRestoreLastSessionEnabled_resumesStoredSession() = + runTest { + every { AuthManager.isRestoreLastSession() } returns true + every { AuthManager.getLastOpenedSessionId() } returns "session-stored-999" + + val resumeParamsSlot = slot>() + every { + HermesWsClient.send(WsMethods.SESSION_RESUME, capture(resumeParamsSlot), any()) + } answers { + "req-resume-test" + } + + val viewModel = createViewModel() + advanceUntilIdle() + + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + + io.mockk.verify { + HermesWsClient.send( + WsMethods.SESSION_RESUME, + any(), + any(), + ) + } + assertEquals("session-stored-999", resumeParamsSlot.captured["session_id"]) + } + + @Test + fun testHandleGatewayReady_withRestoreLastSessionDisabled_createsNewSession() = + runTest { + every { AuthManager.isRestoreLastSession() } returns false + every { AuthManager.getLastOpenedSessionId() } returns "session-stored-999" + + val viewModel = createViewModel() + advanceUntilIdle() + + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + + io.mockk.verify { + HermesWsClient.send( + WsMethods.SESSION_CREATE, + any(), + any(), + ) + } + io.mockk.verify(exactly = 0) { + HermesWsClient.send( + WsMethods.SESSION_RESUME, + any(), + any(), + ) + } + } + + @Test + fun testSwitchSession_updatesLastOpenedSessionId() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + viewModel.switchSession("session-other-456") + advanceUntilIdle() + + io.mockk.verify { AuthManager.setLastOpenedSessionId("session-other-456") } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index a029d76b..6b09f2f5 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -95,6 +95,10 @@ class SlashCommandDispatchRpcTest { every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.isRestoreLastSession() } returns false + every { AuthManager.getLastOpenedSessionId() } returns null + every { AuthManager.setLastOpenedSessionId(any()) } returns Unit + every { AuthManager.clearLastOpenedSessionId() } returns Unit every { HermesWsClient.events } returns mockEventsFlow every { HermesWsClient.connectionStatus } returns mockConnectionStatus every { HermesWsClient.connect() } answers { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index c50acacc..b23b6ec1 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -63,6 +63,10 @@ class SettingsViewModelTest { every { AuthManager.getAppLanguage() } returns "system" every { AuthManager.getToken() } returns "" every { AuthManager.isAutoReconnect() } returns true + every { AuthManager.isRestoreLastSession() } returns false + every { AuthManager.setRestoreLastSession(any()) } returns Unit + every { AuthManager.clearLastOpenedSessionIdsForConnection(any()) } returns Unit + every { AuthManager.clearLastOpenedSessionId() } returns Unit every { AuthManager.getThemePreference() } returns ThemePreference.SYSTEM every { AuthManager.isUseDynamicColors() } returns true every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT @@ -253,4 +257,26 @@ class SettingsViewModelTest { verify { AuthManager.setSelectedProfileId(any()) } assertEquals(true, viewModel.uiState.value.navigateToLogin) } + + @Test + fun testRestoreLastSession_loadsAndUpdatesState() { + every { AuthManager.isRestoreLastSession() } returns true + val viewModel = createViewModel() + + assertEquals(true, viewModel.uiState.value.restoreLastSession) + + viewModel.onRestoreLastSessionChange(false) + assertEquals(false, viewModel.uiState.value.restoreLastSession) + verify { AuthManager.setRestoreLastSession(false) } + } + + @Test + fun testDeleteProfile_clearsStoredSessionIdsForConnection() { + val viewModel = createViewModel() + + viewModel.deleteProfile("prof-1") + testDispatcher.scheduler.advanceUntilIdle() + + verify { AuthManager.clearLastOpenedSessionIdsForConnection("prof-1") } + } } From 0a2c78213997dc1ba1bab83024b881b1cf2b04b2 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:39:33 +0300 Subject: [PATCH 41/42] fix(chat): keep context meter aligned after model swaps (#1103) --- .../data/model/ContextBreakdownResponse.kt | 43 +- .../data/model/UsageSnapshotResponse.kt | 53 ++- .../ui/chat/ChatModelSwitchDelegate.kt | 111 ++++- .../hermescontrol/ui/chat/ChatViewModel.kt | 408 +++++++++++++----- .../model/ContextBreakdownResponseTest.kt | 20 + .../data/model/UsageSnapshotResponseTest.kt | 20 + .../ui/chat/ChatModelSwitchDelegateTest.kt | 85 +++- .../ui/chat/ChatViewModelTest.kt | 107 +++++ 8 files changed, 720 insertions(+), 127 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt index 1dba8c43..1b29cc39 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt @@ -1,5 +1,11 @@ package com.m57.hermescontrol.data.model +import com.m57.hermescontrol.data.ws.toAny +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull + /** * Live context-window occupancy from the `session.context_breakdown` WS RPC — * the same RPC the Hermes desktop app's status-bar meter uses. @@ -30,20 +36,47 @@ data class ContextBreakdownResponse( * Parse the decoded JSON-RPC result of `session.context_breakdown`. * * The WS event parser decodes JSON numbers as [Double] (`JsonRpcModels.toAny`), - * so every count is read through [Number]. Returns null when the payload is + * so every count is read through [Number]. Also supports raw [JsonObject] from + * [HermesWsClient.request]. Returns null when the payload is * not a map (error/malformed response) — callers treat that as unknown and * keep the last known meter values. */ fun parseContextBreakdown(result: Any?): ContextBreakdownResponse? { - val map = result as? Map<*, *> ?: return null + val rawMap = + when (result) { + is JsonElement -> result.toAny() as? Map<*, *> + is Map<*, *> -> result + else -> null + } ?: return null - fun num(key: String): Long? = (map[key] as? Number)?.toLong() + fun num(key: String): Long? = + when (val value = rawMap[key]) { + is Number -> value.toLong() + is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong() + is String -> value.toLongOrNull() + else -> null + } + + fun intNum(key: String): Int? = + when (val value = rawMap[key]) { + is Number -> value.toInt() + is JsonPrimitive -> value.intOrNull + is String -> value.toIntOrNull() + else -> null + } + + fun str(key: String): String? = + when (val value = rawMap[key]) { + is String -> value + is JsonPrimitive -> value.content + else -> value?.toString() + } return ContextBreakdownResponse( contextUsed = num("context_used"), contextMax = num("context_max"), - contextPercent = (map["context_percent"] as? Number)?.toInt(), + contextPercent = intNum("context_percent"), estimatedTotal = num("estimated_total"), - model = map["model"] as? String, + model = str("model"), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt index 9897320f..c20e1058 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt @@ -1,5 +1,12 @@ package com.m57.hermescontrol.data.model +import com.m57.hermescontrol.data.ws.toAny +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull + /** * Minimal view of the `session.usage` WS RPC result or `session.usage` push event — * only the fields the context widget consumes today. The backend sends many more keys @@ -29,17 +36,47 @@ data class UsageSnapshotResponse( * * Handles both direct usage dict (from RPC response) and nested `{ "usage": { ... } }` * (from push event payload). The WS event parser decodes JSON numbers as [Double] - * (`JsonRpcModels.toAny`), so every count is read through [Number]. + * (`JsonRpcModels.toAny`), so every count is read through [Number]. Also supports raw [JsonObject]. * Returns null when the payload is not a map (error/malformed response) — callers keep the last known value. */ fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? { - val map = result as? Map<*, *> ?: return null - val usageMap = (map["usage"] as? Map<*, *>) ?: map + val rawMap = + when (result) { + is JsonElement -> result.toAny() as? Map<*, *> + is Map<*, *> -> result + else -> null + } ?: return null + val usageMap = (rawMap["usage"] as? Map<*, *>) ?: rawMap + + fun num(key: String): Long? = + when (val value = usageMap[key]) { + is Number -> value.toLong() + is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong() + is String -> value.toLongOrNull() + else -> null + } + + fun intNum(key: String): Int? = + when (val value = usageMap[key]) { + is Number -> value.toInt() + is JsonPrimitive -> value.intOrNull + is String -> value.toIntOrNull() + else -> null + } + + fun doubleNum(key: String): Double? = + when (val value = usageMap[key]) { + is Number -> value.toDouble() + is JsonPrimitive -> value.doubleOrNull ?: value.longOrNull?.toDouble() + is String -> value.toDoubleOrNull() + else -> null + } + return UsageSnapshotResponse( - compressions = (usageMap["compressions"] as? Number)?.toInt(), - contextUsed = (usageMap["context_used"] as? Number)?.toLong(), - contextMax = (usageMap["context_max"] as? Number)?.toLong(), - totalTokens = (usageMap["total"] as? Number)?.toLong(), - avgTps = (usageMap["avg_tps"] as? Number)?.toDouble(), + compressions = intNum("compressions"), + contextUsed = num("context_used"), + contextMax = num("context_max"), + totalTokens = num("total"), + avgTps = doubleNum("avg_tps"), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt index 6a1f687e..3d440021 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -22,6 +22,7 @@ import java.util.concurrent.ConcurrentHashMap private data class ActiveModelSwitch( val spec: String, val previousModel: String?, + val sequence: Long, ) private data class ActiveFastSwitch( @@ -44,6 +45,7 @@ class ChatModelSwitchDelegate( private val addAssistantMessage: (text: String) -> Unit, private val handleSlashCommand: (command: String) -> Unit, private val fetchContextUsage: () -> Unit, + private val onModelSwitchInitiated: () -> Unit = {}, private val getModelOptionsCall: suspend ( refresh: Boolean, ) -> NetworkResult = @@ -56,6 +58,9 @@ class ChatModelSwitchDelegate( private val fastRejectedModels = ConcurrentHashMap.newKeySet() private var activeModelSwitchConfirmation: ActiveModelSwitch? = null private var optimisticPreviousModel: String? = null + private var lastConfirmedModel: String? = null + private var unconfirmedTargetModel: String? = null + private var modelSwitchSequence: Long = 0L private var cachedModelOptions: List = emptyList() fun isModelPickerCommand(command: String): Boolean { @@ -142,20 +147,37 @@ class ChatModelSwitchDelegate( uiState.update { it.copy(modelPickerPinned = currentPinned) } } + fun consumeOptimisticPreviousModel(): String? = optimisticPreviousModel.also { optimisticPreviousModel = null } + fun sendSlashModel( provider: String, model: String, ) { - optimisticPreviousModel = uiState.value.currentSessionModel + val targetModelLabel = "$provider/$model" + val isDifferent = !targetModelLabel.equals(uiState.value.currentSessionModel, ignoreCase = true) + if (!isDifferent) { + uiState.update { + it.copy( + showModelPicker = false, + modelPickerLoading = false, + ) + } + return + } + if (optimisticPreviousModel == null) { + optimisticPreviousModel = uiState.value.currentSessionModel + } + unconfirmedTargetModel = targetModelLabel uiState.update { it.copy( showModelPicker = false, modelPickerLoading = false, - currentSessionModel = "$provider/$model", + currentSessionModel = targetModelLabel, + fullContextTokens = null, ) } + onModelSwitchInitiated() syncCurrentModelCapabilities() - fetchContextUsage() handleSlashCommand("/model $model --provider $provider --session") } @@ -174,8 +196,15 @@ class ChatModelSwitchDelegate( } else { command.trim() } - val previousModel = optimisticPreviousModel ?: uiState.value.currentSessionModel - optimisticPreviousModel = null + val previousModel = lastConfirmedModel ?: optimisticPreviousModel ?: uiState.value.currentSessionModel + // Retain optimisticPreviousModel for consumeOptimisticPreviousModel() when SessionInfo arrives. + if (optimisticPreviousModel == null && !spec.equals(uiState.value.currentSessionModel, ignoreCase = true)) { + optimisticPreviousModel = uiState.value.currentSessionModel + uiState.update { it.copy(fullContextTokens = null) } + onModelSwitchInitiated() + } + unconfirmedTargetModel = spec + val switchSeq = ++modelSwitchSequence val params = mutableMapOf( "key" to "model", @@ -191,7 +220,7 @@ class ChatModelSwitchDelegate( params, ) { id -> trackRequest(id, WsMethods.CONFIG_SET) - pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel) + pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel, switchSeq) } } } @@ -207,13 +236,15 @@ class ChatModelSwitchDelegate( if (key == "model") { val confirmRequired = map["confirm_required"] as? Boolean ?: false if (confirmRequired) { - val confirmMessage = - (map["confirm_message"] as? String) - ?: (map["warning"] as? String) - ?: "This model requires confirmation to switch. Continue?" - activeModelSwitchConfirmation = pending - uiState.update { - it.copy(modelSwitchConfirmMessage = confirmMessage) + if (pending != null && pending.sequence == modelSwitchSequence) { + val confirmMessage = + (map["confirm_message"] as? String) + ?: (map["warning"] as? String) + ?: "This model requires confirmation to switch. Continue?" + activeModelSwitchConfirmation = pending + uiState.update { + it.copy(modelSwitchConfirmMessage = confirmMessage) + } } } } else if (key == "fast") { @@ -236,13 +267,18 @@ class ChatModelSwitchDelegate( fun dismissModelSwitchConfirm() { val pending = activeModelSwitchConfirmation activeModelSwitchConfirmation = null + optimisticPreviousModel = null + unconfirmedTargetModel = null + val revertModel = lastConfirmedModel ?: pending?.previousModel uiState.update { it.copy( modelSwitchConfirmMessage = null, - currentSessionModel = pending?.previousModel ?: it.currentSessionModel, + currentSessionModel = revertModel ?: it.currentSessionModel, + fullContextTokens = null, ) } syncCurrentModelCapabilities() + onModelSwitchInitiated() fetchContextUsage() } @@ -282,6 +318,33 @@ class ChatModelSwitchDelegate( id: String, error: Any?, ) { + val pendingModel = pendingModelSwitchRequests.remove(id) + if (pendingModel != null && pendingModel.sequence == modelSwitchSequence) { + val revertModel = lastConfirmedModel ?: pendingModel.previousModel + optimisticPreviousModel = null + unconfirmedTargetModel = null + activeModelSwitchConfirmation = null + if (revertModel != null) { + uiState.update { + it.copy( + currentSessionModel = revertModel, + fullContextTokens = null, + modelSwitchConfirmMessage = null, + ) + } + syncCurrentModelCapabilities() + onModelSwitchInitiated() + fetchContextUsage() + } else { + uiState.update { + it.copy( + fullContextTokens = null, + modelSwitchConfirmMessage = null, + ) + } + onModelSwitchInitiated() + } + } val pendingFast = pendingFastSwitchRequests.remove(id) if (pendingFast != null) { val errorMsg = @@ -300,6 +363,26 @@ class ChatModelSwitchDelegate( } } + fun isSwitchPending(): Boolean = + optimisticPreviousModel != null || + unconfirmedTargetModel != null || + activeModelSwitchConfirmation != null + + fun onModelConfirmed(modelLabel: String) { + lastConfirmedModel = modelLabel + optimisticPreviousModel = null + unconfirmedTargetModel = null + } + + fun reset() { + pendingModelSwitchRequests.clear() + pendingFastSwitchRequests.clear() + activeModelSwitchConfirmation = null + optimisticPreviousModel = null + lastConfirmedModel = null + unconfirmedTargetModel = null + } + fun setReasoningLevel(level: String?) { uiState.update { it.copy(reasoningLevel = level) } val sessionId = runtimeSessionId() ?: return diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 1c23f3b8..3cf0199e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -28,9 +28,11 @@ import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.ui.common.ActionProgressController +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -394,6 +396,18 @@ class ChatViewModel( /** Tracks the auto-clear coroutine for reaction animations. */ private var reactionClearJob: Job? = null + /** Last confirmed model label pushed by SessionInfo or SessionResume from backend (issue #1103). */ + private var lastConfirmedSessionModel: String? = null + + /** Model generation counter to prevent stale context writes across switches (issue #1103). */ + private var modelGeneration: Long = 0L + + /** Request sequence counter for fetchContextUsage calls. */ + private var contextFetchSequence: Long = 0L + + /** Tracks the latest context-usage poll or refetch job. */ + private var contextUsageJob: Job? = null + private val wsClient = HermesWsClient // ── Session persistence ────────────────────────────────────────────── @@ -445,6 +459,7 @@ class ChatViewModel( addAssistantMessage = { text -> addAssistantMessage(text) }, handleSlashCommand = { cmd -> handleSlashCommand(cmd) }, fetchContextUsage = { fetchContextUsage() }, + onModelSwitchInitiated = { onModelSwitchInitiated() }, ) private val credentialPromptsDelegate = @@ -796,16 +811,25 @@ class ChatViewModel( } else { model } - // Issue #817: on a REAL model swap the meter's denominator + // Issue #817 & #1103: on a REAL model swap the meter's denominator // still belongs to the old model until the next fetch. - // Blank it so the chip hides instead of flashing a stale - // window under the new label, and re-fire the fetch so the - // new window lands immediately (no 30s poll wait). Only a - // change from a known label counts — the first SessionInfo - // of a session just sets the label. - val previousLabel = _uiState.value.currentSessionModel + // Check against both lastConfirmedSessionModel and optimisticPreviousModel + // so optimistic model updates in sendSlashModel don't defeat swap detection. + val optimisticPrevious = modelSwitchDelegate.consumeOptimisticPreviousModel() + val previousModel = + lastConfirmedSessionModel + ?: optimisticPrevious + ?: _uiState.value.currentSessionModel val modelSwapped = - previousLabel != null && newModelLabel != null && newModelLabel != previousLabel + previousModel != null && + newModelLabel != null && + !newModelLabel.equals(previousModel, ignoreCase = true) + val initialHydration = previousModel == null && newModelLabel != null + val meterEmpty = _uiState.value.fullContextTokens == null + lastConfirmedSessionModel = newModelLabel ?: lastConfirmedSessionModel + if (newModelLabel != null) { + modelSwitchDelegate.onModelConfirmed(newModelLabel) + } _uiState.update { state -> state.copy( currentSessionModel = newModelLabel ?: state.currentSessionModel, @@ -823,12 +847,19 @@ class ChatViewModel( } modelSwitchDelegate.syncCurrentModelCapabilities() if (modelSwapped) { - // Issue #817: after a swap the REST model/info window is + modelGeneration++ + contextUsageJob?.cancel() + contextUsageJob = null + // Issue #817 & #1103: after a swap the REST model/info window is // PROFILE-scoped and may describe the old model (e.g. a // session-scoped swap) — the meter must not fall back to // it. Wait for the RPC's live context_max instead; the // chip stays hidden until the real window lands. viewModelScope.launch { fetchContextUsage(skipRestFallback = true) } + } else if ((initialHydration || meterEmpty) && newModelLabel != null && + contextUsageJob?.isActive != true + ) { + viewModelScope.launch { fetchContextUsage() } } // Session.info can carry `pending_approval` (reconnect // reconciliation) — surface it unless already on screen. @@ -1151,6 +1182,23 @@ class ChatViewModel( terminalBackend = terminalBackend ?: it.terminalBackend, ) } + val resumedModelLabel = + if (model != null && provider != null) { + "$provider/$model" + } else { + model + } + if (resumedModelLabel != null) { + if (lastConfirmedSessionModel != null && + !resumedModelLabel.equals(lastConfirmedSessionModel, ignoreCase = true) + ) { + modelGeneration++ + contextUsageJob?.cancel() + contextUsageJob = null + _uiState.update { it.copy(fullContextTokens = null) } + } + lastConfirmedSessionModel = resumedModelLabel + } modelSwitchDelegate.syncCurrentModelCapabilities() // Mirror the active runtime session id app-wide (issue #532). ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId) @@ -2558,6 +2606,11 @@ class ChatViewModel( ): Long { val generation = ++sessionGeneration cancelResumeRetry() + contextUsageJob?.cancel() + contextUsageJob = null + modelGeneration++ + lastConfirmedSessionModel = null + modelSwitchDelegate.reset() resumedGeneration = -1L hydratedGeneration = -1L runtimeSessionId = null @@ -3043,6 +3096,57 @@ class ChatViewModel( } } + internal fun onModelSwitchInitiated() { + modelGeneration++ + contextUsageJob?.cancel() + contextUsageJob = null + _uiState.update { it.copy(fullContextTokens = null) } + } + + private fun isCurrentContextFetch( + sessionId: String, + targetSessionGeneration: Long, + targetModelGeneration: Long, + targetRequestSequence: Long, + ): Boolean = + targetSessionGeneration == sessionGeneration && + targetModelGeneration == modelGeneration && + targetRequestSequence == contextFetchSequence && + _uiState.value.currentSessionId == sessionId + + internal fun isMatchingModel( + currentModel: String?, + info: com.m57.hermescontrol.data.model.ModelInfoResponse?, + ): Boolean { + if (currentModel.isNullOrBlank() || info == null || info.model.isNullOrBlank()) { + return false + } + val restModel = info.model + val restProvider = info.provider + return if (currentModel.contains('/')) { + val curProvider = currentModel.substringBefore('/') + val curModel = currentModel.substringAfter('/') + if (!restProvider.isNullOrBlank()) { + curProvider.equals(restProvider, ignoreCase = true) && + curModel.equals(restModel, ignoreCase = true) + } else { + curModel.equals(restModel, ignoreCase = true) + } + } else { + currentModel.equals(restModel, ignoreCase = true) + } + } + + internal fun isMatchingRpcModel( + currentModel: String?, + rpcModel: String?, + ): Boolean { + if (currentModel.isNullOrBlank() || rpcModel.isNullOrBlank()) return false + if (currentModel.equals(rpcModel, ignoreCase = true)) return true + return currentModel.contains('/') && + currentModel.substringAfter('/').equals(rpcModel, ignoreCase = true) + } + /** * Refresh the context meter: used / full tokens for the current session. * @@ -3068,105 +3172,211 @@ class ChatViewModel( fun fetchContextUsage(skipRestFallback: Boolean = false) { val sessionId = _uiState.value.currentSessionId ?: return val profile = AuthManager.activeProfileId.value - viewModelScope.launch(ioDispatcher) { - // Denominator fallback: full context window (cheap, public, rarely - // changes). The RPC's context_max below overrides it when present. - // Kept as a local (not a state write) so both sources resolve - // before ONE atomic update below (issue #817 — two independent - // writes let a stale pre-swap value override a fresh one mid-swap). - val fullResult = - safeApiCall { ApiClient.hermesApi.getModelInfo() } - val restFull = - if (fullResult is NetworkResult.Success) { - fullResult.data.effective_context_length - ?: fullResult.data.auto_context_length - ?: fullResult.data.config_context_length - } else { - null + val targetSessionGeneration = sessionGeneration + val targetModelGeneration = modelGeneration + val targetRequestSequence = ++contextFetchSequence + val isSwitchPending = modelSwitchDelegate.isSwitchPending() + + contextUsageJob?.cancel() + contextUsageJob = + viewModelScope.launch(ioDispatcher) { + // Denominator fallback: full context window (cheap, public, rarely + // changes). The RPC's context_max below overrides it when present. + // Kept as a local (not a state write) so both sources resolve + // before ONE atomic update below (issue #817 — two independent + // writes let a stale pre-swap value override a fresh one mid-swap). + val fullResult = + safeApiCall { ApiClient.hermesApi.getModelInfo() } + coroutineContext.ensureActive() + if (!isCurrentContextFetch( + sessionId, + targetSessionGeneration, + targetModelGeneration, + targetRequestSequence, + ) + ) { + return@launch } - // Numerator: live context occupancy from the gateway's live agent, - // via the same RPC the desktop meter uses. `context_used` is the - // real current prompt size (drops after compression); `context_max` - // is the compressor's actual window. Any failure keeps the last - // known values — never blank the meter over a transient RPC error. - // - // These RPCs resolve the session against the gateway's LIVE runtime - // registry (_sess_nowait) — the storage session id 4001s "session - // not found" until session.resume has registered it. ChatScreen's - // sync effect fires this immediately on session switch, before the - // resume result lands, so skip the RPCs until resume confirms the - // runtime id. The REST parts below stay live (they key on the - // storage id). - var rpcUsed: Long? = null - var rpcMax: Long? = null - val rpcSessionId = runtimeSessionId - if (rpcSessionId != null) { - try { - val result = - sendRpcAndAwait( - WsMethods.SESSION_CONTEXT_BREAKDOWN, - mapOf("session_id" to rpcSessionId), - ) - val ctx = parseContextBreakdown(result) - if (ctx != null) { - rpcUsed = ctx.contextUsed?.takeIf { it > 0L } - rpcMax = ctx.contextMax?.takeIf { it > 0L } + val restFull = + if (fullResult is NetworkResult.Success && !isSwitchPending) { + val info = fullResult.data + val currentModel = _uiState.value.currentSessionModel + // Issue #1103: only use profile REST model/info as fallback if it + // matches the current session model identity. If the session was switched to + // another model (e.g. Solar, Gemini), the profile-level model/info describes + // a different model and must never poison the session's context window. + if (isMatchingModel(currentModel, info)) { + info.effective_context_length + ?: info.auto_context_length + ?: info.config_context_length + } else { + null + } + } else { + null } - } catch (_: Exception) { - // Best-effort: RPC error/timeout/disconnect — keep last values. - } - // Compression count: how many times this session has been compacted - // (session.usage → compressions). Feeds the "compressed ×N" badge — - // the same usage snapshot the desktop status bar reads. - try { - val usage = - sendRpcAndAwait( - WsMethods.SESSION_USAGE, - mapOf("session_id" to rpcSessionId), + // Numerator: live context occupancy from the gateway's live agent, + // via the same RPC the desktop meter uses. `context_used` is the + // real current prompt size (drops after compression); `context_max` + // is the compressor's actual window. Any failure keeps the last + // known values — never blank the meter over a transient RPC error. + // + // These RPCs resolve the session against the gateway's LIVE runtime + // registry (_sess_nowait) — the storage session id 4001s "session + // not found" until session.resume has registered it. ChatScreen's + // sync effect fires this immediately on session switch, before the + // resume result lands, so skip the RPCs until resume confirms the + // runtime id. The REST parts below stay live (they key on the + // storage id). + var rpcUsed: Long? = null + var rpcMax: Long? = null + val rpcSessionId = runtimeSessionId + if (rpcSessionId != null) { + try { + val result = + sendRpcAndAwait( + WsMethods.SESSION_CONTEXT_BREAKDOWN, + mapOf("session_id" to rpcSessionId), + ) + coroutineContext.ensureActive() + val ctx = parseContextBreakdown(result) + if (ctx != null) { + val currentModel = _uiState.value.currentSessionModel + val modelMatches = + if (ctx.model != null) { + isMatchingRpcModel(currentModel, ctx.model) + } else { + !isSwitchPending + } + if (modelMatches) { + rpcUsed = ctx.contextUsed?.takeIf { it > 0L } + rpcMax = ctx.contextMax?.takeIf { it > 0L } + } + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Best-effort: RPC error/timeout/disconnect — keep last values. + } + if (!isCurrentContextFetch( + sessionId, + targetSessionGeneration, + targetModelGeneration, + targetRequestSequence, ) - val snapshot = parseUsageSnapshot(usage) - if (snapshot != null && snapshot.compressions != null) { - _uiState.update { it.copy(compressionCount = snapshot.compressions) } + ) { + return@launch + } + // Compression count: how many times this session has been compacted + // (session.usage → compressions). Feeds the "compressed ×N" badge — + // the same usage snapshot the desktop status bar reads. + if (!isSwitchPending) { + try { + val usage = + sendRpcAndAwait( + WsMethods.SESSION_USAGE, + mapOf("session_id" to rpcSessionId), + ) + coroutineContext.ensureActive() + val snapshot = parseUsageSnapshot(usage) + if (snapshot != null && snapshot.compressions != null) { + _uiState.update { current -> + if (!isCurrentContextFetch( + sessionId, + targetSessionGeneration, + targetModelGeneration, + targetRequestSequence, + ) + ) { + current + } else { + current.copy(compressionCount = snapshot.compressions) + } + } + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Best-effort: keep the last known badge value. + } } - } catch (_: Exception) { - // Best-effort: keep the last known badge value. } - } - // Issue #817: single atomic denominator write. The RPC's live - // context_max wins, REST model/info is the fallback, and a stale - // value from a pre-swap fetch can never overwrite a fresh one — - // the meter always shows ONE coherent window. - _uiState.update { current -> - val fallbackFull = - if (skipRestFallback) current.fullContextTokens else restFull ?: current.fullContextTokens - current.copy( - usedContextTokens = rpcUsed ?: current.usedContextTokens, - fullContextTokens = rpcMax ?: fallbackFull, - ) - } - // Detail-sheet accounting (cumulative REST counters, informational). - val usedResult = - safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) } - if (usedResult is NetworkResult.Success) { - val d = usedResult.data - val used = d.input_tokens - if (used != null) { - _uiState.update { - it.copy( - contextBreakdown = - ContextBreakdown( - inputTokens = used, - outputTokens = d.output_tokens ?: 0L, - cacheReadTokens = d.cache_read_tokens ?: 0L, - cacheWriteTokens = d.cache_write_tokens ?: 0L, - reasoningTokens = d.reasoning_tokens ?: 0L, - messageCount = d.message_count ?: 0, - ), + coroutineContext.ensureActive() + if (!isCurrentContextFetch( + sessionId, + targetSessionGeneration, + targetModelGeneration, + targetRequestSequence, + ) + ) { + return@launch + } + // Issue #817 & #1103: single atomic denominator write. The RPC's live + // context_max wins, REST model/info is the fallback, and a stale + // value from a pre-swap fetch can never overwrite a fresh one — + // the meter always shows ONE coherent window. + _uiState.update { current -> + if (!isCurrentContextFetch( + sessionId, + targetSessionGeneration, + targetModelGeneration, + targetRequestSequence, + ) + ) { + current + } else { + val fallbackFull = + if (skipRestFallback) current.fullContextTokens else restFull ?: current.fullContextTokens + current.copy( + usedContextTokens = rpcUsed ?: current.usedContextTokens, + fullContextTokens = rpcMax ?: fallbackFull, ) } } + // Detail-sheet accounting (cumulative REST counters, informational). + coroutineContext.ensureActive() + if (!isCurrentContextFetch( + sessionId, + targetSessionGeneration, + targetModelGeneration, + targetRequestSequence, + ) + ) { + return@launch + } + val usedResult = + safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) } + coroutineContext.ensureActive() + if (usedResult is NetworkResult.Success) { + val d = usedResult.data + val used = d.input_tokens + if (used != null) { + _uiState.update { current -> + if (!isCurrentContextFetch( + sessionId, + targetSessionGeneration, + targetModelGeneration, + targetRequestSequence, + ) + ) { + current + } else { + current.copy( + contextBreakdown = + ContextBreakdown( + inputTokens = used, + outputTokens = d.output_tokens ?: 0L, + cacheReadTokens = d.cache_read_tokens ?: 0L, + cacheWriteTokens = d.cache_write_tokens ?: 0L, + reasoningTokens = d.reasoning_tokens ?: 0L, + messageCount = d.message_count ?: 0, + ), + ) + } + } + } + } } - } } private suspend fun fetchServerMessageCount( diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ContextBreakdownResponseTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ContextBreakdownResponseTest.kt index bbd638be..9cb07ae3 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/ContextBreakdownResponseTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ContextBreakdownResponseTest.kt @@ -84,4 +84,24 @@ class ContextBreakdownResponseTest { assertEquals(262144L, parsed?.contextMax) assertEquals(0, parsed?.contextPercent) } + + @Test + fun `JsonObject and JsonPrimitive payloads parse successfully`() { + val json = + kotlinx.serialization.json.buildJsonObject { + put("context_used", kotlinx.serialization.json.JsonPrimitive(52428)) + put("context_max", kotlinx.serialization.json.JsonPrimitive(524288)) + put("context_percent", kotlinx.serialization.json.JsonPrimitive(10)) + put("estimated_total", kotlinx.serialization.json.JsonPrimitive(52428)) + put("model", kotlinx.serialization.json.JsonPrimitive("upstage/solar-pro4:free")) + } + + val parsed = parseContextBreakdown(json) + + assertEquals(52428L, parsed?.contextUsed) + assertEquals(524288L, parsed?.contextMax) + assertEquals(10, parsed?.contextPercent) + assertEquals(52428L, parsed?.estimatedTotal) + assertEquals("upstage/solar-pro4:free", parsed?.model) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt index 341e2bb9..ac27f750 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt @@ -30,4 +30,24 @@ class UsageSnapshotResponseTest { assertNull(parseUsageSnapshot("oops")) assertNull(parseUsageSnapshot(listOf(1.0))) } + + @Test + fun `JsonObject and JsonPrimitive payloads parse successfully`() { + val json = + kotlinx.serialization.json.buildJsonObject { + put("compressions", kotlinx.serialization.json.JsonPrimitive(3)) + put("context_used", kotlinx.serialization.json.JsonPrimitive(12000)) + put("context_max", kotlinx.serialization.json.JsonPrimitive(200000)) + put("total", kotlinx.serialization.json.JsonPrimitive(50000)) + put("avg_tps", kotlinx.serialization.json.JsonPrimitive(45.5)) + } + + val parsed = parseUsageSnapshot(json) + + assertEquals(3, parsed?.compressions) + assertEquals(12000L, parsed?.contextUsed) + assertEquals(200000L, parsed?.contextMax) + assertEquals(50000L, parsed?.totalTokens) + assertEquals(45.5, parsed?.avgTps ?: 0.0, 0.001) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt index 478fe04a..ec66a567 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt @@ -30,6 +30,7 @@ class ChatModelSwitchDelegateTest { private val slashCommands = mutableListOf() private val assistantMessages = mutableListOf() private var contextRefetched = 0 + private var modelSwitchInitiated = 0 private var pinnedList = mutableListOf(PinnedModel("anthropic", "claude-3")) private val fakeResponse = @@ -60,6 +61,7 @@ class ChatModelSwitchDelegateTest { addAssistantMessage = { assistantMessages.add(it) }, handleSlashCommand = { slashCommands.add(it) }, fetchContextUsage = { contextRefetched++ }, + onModelSwitchInitiated = { modelSwitchInitiated++ }, getModelOptionsCall = { NetworkResult.Success(fakeResponse) }, getPinnedModels = { pinnedList }, savePinnedModels = { pinnedList = it.toMutableList() }, @@ -92,7 +94,24 @@ class ChatModelSwitchDelegateTest { assertEquals("openai/gpt-4o", uiState.value.currentSessionModel) assertFalse(uiState.value.showModelPicker) assertEquals(listOf("/model gpt-4o --provider openai --session"), slashCommands) - assertEquals(1, contextRefetched) + assertEquals(1, modelSwitchInitiated) + } + + @Test + fun sendSlashModel_withSameModel_doesNotInitiateSwitchOrBlankTokens() { + uiState.value = + uiState.value.copy( + currentSessionModel = "openai/gpt-4o", + fullContextTokens = 128_000L, + showModelPicker = true, + ) + val initialInitiated = modelSwitchInitiated + delegate.sendSlashModel("openai", "gpt-4o") + assertEquals("openai/gpt-4o", uiState.value.currentSessionModel) + assertEquals(128_000L, uiState.value.fullContextTokens) + assertEquals(initialInitiated, modelSwitchInitiated) + assertEquals(emptyList(), slashCommands) + assertFalse(uiState.value.showModelPicker) } @Test @@ -139,6 +158,21 @@ class ChatModelSwitchDelegateTest { assertEquals("anthropic/claude-3", uiState.value.currentSessionModel) } + @Test + fun handleConfigSetError_rollsBackToPreviousModel_onlyWhenMatchingCurrentSequence() = + testScope.runTest { + delegate.sendSlashModel("openai", "gpt-4o") + advanceUntilIdle() + delegate.handleModelSwitch("/model gpt-4o --provider openai --session") + advanceUntilIdle() + assertEquals("openai/gpt-4o", uiState.value.currentSessionModel) + + // Current switch error rolls back + delegate.handleConfigSetError("req-1", "Model unavailable") + assertEquals("anthropic/claude-3", uiState.value.currentSessionModel) + assertNull(uiState.value.fullContextTokens) + } + @Test fun toggleFastMode_sendsConfigSetFast_andUpdatesOnAck() = testScope.runTest { @@ -207,4 +241,53 @@ class ChatModelSwitchDelegateTest { advanceUntilIdle() assertEquals(sentCount, sentMethods.size) } + + @Test + fun handleConfigSetResult_staleSequenceConfirmRequired_isIgnored() = + testScope.runTest { + delegate.sendSlashModel("openai", "gpt-4o") + advanceUntilIdle() + delegate.handleModelSwitch("/model gpt-4o --provider openai --session") + advanceUntilIdle() + + // A second switch arrives before first confirms + delegate.sendSlashModel("anthropic", "claude-3-5-sonnet") + advanceUntilIdle() + delegate.handleModelSwitch("/model claude-3-5-sonnet --provider anthropic --session") + advanceUntilIdle() + + // First switch response arrives with confirm_required + delegate.handleConfigSetResult( + id = "req-1", + result = mapOf("key" to "model", "confirm_required" to true), + ) + // Guarded: sequence 1 != current sequence 2, so confirmation is ignored + assertNull(uiState.value.modelSwitchConfirmMessage) + } + + @Test + fun rapidPicks_preservesOriginalConfirmedModelForRollback() = + testScope.runTest { + // Initially confirmed model is anthropic/claude-3 + delegate.onModelConfirmed("anthropic/claude-3") + uiState.value = uiState.value.copy(currentSessionModel = "anthropic/claude-3") + + // Pick 1: switch to gpt-4o + delegate.sendSlashModel("openai", "gpt-4o") + advanceUntilIdle() + delegate.handleModelSwitch("/model gpt-4o --provider openai --session") + advanceUntilIdle() + assertEquals("openai/gpt-4o", uiState.value.currentSessionModel) + + // Pick 2: rapidly pick solar without gpt-4o ever confirming + delegate.sendSlashModel("nous", "solar") + advanceUntilIdle() + delegate.handleModelSwitch("/model solar --provider nous --session") + advanceUntilIdle() + assertEquals("nous/solar", uiState.value.currentSessionModel) + + // Error on second switch (req-2) should roll back to confirmed model, not optimistic gpt-4o + delegate.handleConfigSetError("req-2", "Solar failed") + assertEquals("anthropic/claude-3", uiState.value.currentSessionModel) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 1803c960..14c82d19 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -2916,6 +2916,113 @@ class ChatViewModelTest { assertEquals(262_144L, viewModel.uiState.value.fullContextTokens) } + @Test + fun testPickerModelSwitch_blanksMeterAndResolvesNewContextWindow_issue1103() = + runTest { + stubEmptySessionRests("session-a") + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + // Profile default model is 900k + coEvery { ApiClient.hermesApi.getModelInfo() } returns + retrofit2.Response.success( + com.m57.hermescontrol.data.model.ModelInfoResponse( + model = "gpt-5.6-luna-900k", + provider = "openai-codex", + effective_context_length = 900_000L, + ), + ) + + // Initial session.info for default model + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("model" to "gpt-5.6-luna-900k", "provider" to "openai-codex"), + ), + ) + advanceUntilIdle() + assertEquals("openai-codex/gpt-5.6-luna-900k", viewModel.uiState.value.currentSessionModel) + + var breakdownResult: Any = + mapOf("context_max" to 900_000L, "context_used" to 10_000L) + every { HermesWsClient.request(WsMethods.SESSION_CONTEXT_BREAKDOWN, any(), any()) } answers { + CompletableDeferred(breakdownResult) + } + viewModel.fetchContextUsage() + advanceUntilIdle() + assertEquals(900_000L, viewModel.uiState.value.fullContextTokens) + + // User picks Solar 200k via model picker (sendSlashModel) + viewModel.sendSlashModel("nous", "upstage/solar-pro4:free") + advanceUntilIdle() + assertEquals("nous/upstage/solar-pro4:free", viewModel.uiState.value.currentSessionModel) + assertNull("Picker model switch must immediately blank meter", viewModel.uiState.value.fullContextTokens) + + // Backend replies with session.info confirming Solar + // Breakdown RPC returns JsonObject (from wire) with 524288 context_max + breakdownResult = + kotlinx.serialization.json.buildJsonObject { + put("context_max", kotlinx.serialization.json.JsonPrimitive(524288)) + put("context_used", kotlinx.serialization.json.JsonPrimitive(20000)) + } + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("model" to "upstage/solar-pro4:free", "provider" to "nous"), + ), + ) + advanceUntilIdle() + + // Verified: meter resolves to Solar's 524288 window, NOT profile's 900k + assertEquals(524288L, viewModel.uiState.value.fullContextTokens) + + // Periodic background sync with skipRestFallback=false must NOT overwrite with profile's 900k + viewModel.fetchContextUsage(skipRestFallback = false) + advanceUntilIdle() + assertEquals(524288L, viewModel.uiState.value.fullContextTokens) + } + + @Test + fun testIsMatchingRpcModel_namespacedAndProviderMatching() = + runTest { + stubEmptySessionRests("session-a") + val (viewModel, _) = createViewModelWithSession() + + // Current session model has provider prefix, rpc model retains internal namespace + assertTrue( + viewModel.isMatchingRpcModel( + "nous/upstage/solar-pro4:free", + "upstage/solar-pro4:free", + ), + ) + // Exact match + assertTrue( + viewModel.isMatchingRpcModel( + "upstage/solar-pro4:free", + "upstage/solar-pro4:free", + ), + ) + // Provider prefix with non-namespaced model + assertTrue( + viewModel.isMatchingRpcModel( + "openai/gpt-4o", + "gpt-4o", + ), + ) + // Never blindly strip model's own namespace: rpcModel missing internal namespace must not match + assertFalse( + viewModel.isMatchingRpcModel( + "nous/upstage/solar-pro4:free", + "solar-pro4:free", + ), + ) + // Mismatch + assertFalse( + viewModel.isMatchingRpcModel( + "nous/upstage/solar-pro4:free", + "claude-3", + ), + ) + } + @Test fun testSessionInfoSameModel_keepsMeterUntouched() = runTest { From c96e3b699ab3d2fc42656b704e397bc74839702a Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:48:55 +0300 Subject: [PATCH 42/42] feat(settings): add Ko-fi support link --- README.md | 6 ++ .../ui/settings/SettingsScreen.kt | 2 + .../ui/settings/components/SupportSection.kt | 57 +++++++++++++++++++ app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-zh/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 7 files changed, 77 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/settings/components/SupportSection.kt diff --git a/README.md b/README.md index 393f0875..d2a72800 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ --- +## Support the project + +If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi](https://ko-fi.com/m_5_7). + +--- + ## Screenshots

diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 071af035..7e4d9ce3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -46,6 +46,7 @@ import com.m57.hermescontrol.SettingsVault import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.settings.components.SupportSection import com.m57.hermescontrol.ui.settings.components.languageLabel @Composable @@ -133,6 +134,7 @@ fun SettingsScreen( ), ), ) + SupportSection() } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/SupportSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/SupportSection.kt new file mode 100644 index 00000000..e09f41bb --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/SupportSection.kt @@ -0,0 +1,57 @@ +package com.m57.hermescontrol.ui.settings.components + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R + +private const val KO_FI_URL = "https://ko-fi.com/m_5_7" + +@Composable +internal fun SupportSection() { + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(1.dp), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text( + text = stringResource(R.string.settings_support_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.settings_support_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = { + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(KO_FI_URL))) + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.settings_support_action)) + } + } + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 44861de2..dc295d82 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -203,6 +203,9 @@ حول التطبيق اللغة Hermes Mobile +ادعم Hermes Mobile +إذا وفّر لك Hermes Mobile الوقت، يمكنك دعم تطويره. +ادعمني على Ko-fi الإصدار رقم البنية تصحيح الأخطاء (Debug) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 32266616..40b7a9ba 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -205,6 +205,9 @@ 연결 테스트 정보 헤르메스 모바일 + Hermes Mobile 후원 + Hermes Mobile이 시간을 절약해 준다면 개발을 후원해 주세요. + Ko-fi에서 후원하기 버전 빌드 디버그 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index af463661..9cc43ed8 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -203,6 +203,9 @@ 关于 语言 Hermes Mobile + 支持 Hermes Mobile + 如果 Hermes Mobile 为你节省了时间,欢迎支持它的开发。 + 在 Ko-fi 上支持我 版本 构建 调试版 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1da97682..9e0a66b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -243,6 +243,9 @@ Allow installs from this source Open settings Retry + Support Hermes Mobile + If Hermes Mobile saves you time, you can support its development. + Support me on Ko-fi %1$d saved