15 Commits
Author SHA1 Message Date
ARIA 0951ebbbce android gateway: raise MAX_MESSAGE_LENGTH to 1M so long replies (and complete HTML artifacts) aren't chunked across fence-reopened messages — the stream consumer defaults to 4096 when the adapter sets no cap, and WS has no message-size limit 2026-08-21 00:43:42 +02:00
ARIA a8c4418aff HTML artifacts: render agent-sent HTML/CSS/JS code blocks in an in-app WebView (Android platform WebView, desktop JCEF/KCEF) via a full-screen preview; artifact card with Preview button + code toggle, shown only once the message stops streaming; webview-multiplatform 1.9.40 + KCEF deps, detection + unit tests 2026-08-21 00:43:39 +02:00
ARIA 8100730cba User bubbles: move timestamp + delivery checkmarks to their own bottom-right line (Telegram look, matching agent bubbles) instead of inline at the end of the text 2026-08-21 00:01:13 +02:00
ARIA bff363d579 Chat bubbles: render markdown (bold/italic/tables/syntax-highlighted code) for agent + user messages; tap-to-copy inline code with flash, code-block copy button; preserve user newlines; removed stale HANDOFF doc 2026-08-20 23:29:43 +02:00
ARIA 5b9427b10d Channel badges: flat Material star (default) + heart (favorite) icons instead of text glyphs, so favorites no longer look like the default channel 2026-08-20 21:07:52 +02:00
ARIA f5e39c0752 Channel context menu: hide Delete for the default channel (it can be renamed but not deleted; server already enforces this) 2026-08-20 21:00:36 +02:00
ARIA b681e547e7 Channel context menu: long-press/right-click a row → rename, favorite/unfavorite, add/change/remove icon (color or image), delete; removed star/delete row buttons; favorite + icon/color synced across devices 2026-08-20 20:55:24 +02:00
ARIA 0d99ee5cf5 Back-to-bottom button: chevron FAB appears when scrolled up, tap glides to latest; new messages no longer yank the view while reading history; lane switch always lands on latest 2026-08-20 20:15:12 +02:00
ARIA 8b40eb6e4c App icon: winged-bubble adaptive icon (Android) + window/jpackage icon (desktop); concept art in app/icons/ 2026-08-20 19:48:57 +02:00
ARIA ccc40213ca Compact single-row header: tappable status bubble (green/yellow-pulsing/red) + search, status toast on tap; removed overflow menu (rename/forget) 2026-08-20 19:33:45 +02:00
ARIA c668b209b2 User theme: customizable bubble colors + background (color/image) in Settings → Appearance
- UserTheme model (ARGB ints) + LocalUserTheme, persisted via SecureStore
- HSV color picker dialog (Apply keeps open, OK applies+closes, Cancel)
- Background: solid color or image (SAF on Android, JFileChooser on desktop)
- Reasoning block: 0.6 black overlay on agent bubble color (adapts automatically)
- Contrast-aware text on bubbles + pinned contentColor on root Surface
2026-08-20 19:14:35 +02:00
ARIA de3b5fd956 Added official hermes-gateway status messages (new comment category instead of tool calls and messages) 2026-08-20 18:11:36 +02:00
ARIA 6f4bf3cb7f Long-press message selection + delete (message.delete frame, outbox removal, all-device sync) 2026-08-20 16:51:19 +02:00
ARIA 60ec2b44a7 Auto-threading, history pagination, streaming toggle + tool/reasoning display settings
- Auto-threading (Telegram topic-mode workflow): message.send {auto_thread}
  mints a fresh AI-named thread (instant derived title, LLM upgrade via
  channel.renamed); channel.created {auto:true}; the app jumps into the new
  thread and relocates the optimistic pending bubble.
- history frame: paged full message history for initial channel open /
  scroll-up pagination (reconstructed from the outbox log).
- Streaming on/off: gateway side (display.platforms.android.streaming) plus a
  per-device app toggle (Settings → Streaming); reasoning/model/tokens carried
  on message frames.
- Context menu: long-press (touch) / right-click (desktop) thread affordances
  via a KMP rightClick expect/actual.
- Settings → Reasoning: auto-collapse long reasoning blocks (default on).
- Tool detail: the gateway now always supplies full tool data — it forces
  verbose tool progress (full args → tool.start.args) and captures each
  completed call via the post_tool_call hook (output/duration/ok → tool.end).
  The app reveals the full call + output on expand (Truncated) and
  auto-expands cards in Everything mode.
2026-08-20 16:26:52 +02:00
ARIA 1d67de5f12 Composer auto-grow (1→5 lines) + in-field attach; settings screen + persisted prefs
- Composer input starts at one line and auto-grows up to 5 lines (then scrolls),
  measured via onTextLayout; attach (paperclip) moved inside the field, right of
  the text, per the Telegram reference.

- New SettingsScreen (drawer/rail entry) for Threads + Tool detail; both now
  persist via SecureStore (Android prefs / desktop JSON).

- Add AGENTS.md; rename reference screenshot to telegram_screenshot_reference.jpg.
2026-08-20 13:12:24 +02:00
63 changed files with 4525 additions and 326 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
# AGENTS.md
## Hard rules
- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home.
- The plugin is installed by symlink: `~/.hermes/plugins/android` → `<repo>/gateway-plugin` (already set up on this machine).
## Layout
- `gateway-plugin/` — Python hermes platform plugin (`android`). No build step, zero new deps (stdlib + hermes-provided `websockets`/`httpx`). `protocol.py` is the frame source of truth, mirrored in `app/shared/.../protocol/Protocol.kt` and `docs/protocol/frames.schema.json`.
- `app/` — one Compose Multiplatform Gradle project: `:shared` (KMP, most of the code; `jvmMain` is shared by the android and desktop targets since both are JVM-based), `:androidApp` (thin shell, package `dev.iris.app`), `:desktopApp` (thin shell).
- `docs/` — numbered reference library; read `docs/00-overview.md` first. Locked decisions: `docs/16-open-questions.md`.
## Commands
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
- Gateway: `hermes gateway setup` (one-time; generates `ANDROID_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
- Kotlin tests: `cd app && ./gradlew :shared:testDebugUnitTest` / `:shared:desktopTest` (no test sources exist yet).
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`.
## Environment / pairing quirks
- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry.
- WS default bind is `127.0.0.1`; for a phone on the LAN set `ANDROID_WS_HOST` to the gateway's LAN IP.
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
- JDK 17; no system Gradle — always the wrapper (`./gradlew`).
- Desktop jpackage on Linux/JDK 17 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
## Testing quirks
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `ANDROID_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
- Gateway logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`.
@@ -11,6 +11,8 @@
<application
android:label="Iris"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="true"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.NoActionBar">
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient
android:startColor="#262B3A"
android:endColor="#14171F"
android:startX="0"
android:startY="0"
android:endX="0"
android:endY="108" />
</aapt:attr>
</path>
<path android:pathData="M33.96,51.18a29.75,29.75 0 1,0 59.5,0a29.75,29.75 0 1,0 -59.5,0z">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="63.71"
android:centerY="51.18"
android:gradientRadius="29.75"
android:startColor="#384F7CFF"
android:endColor="#004F7CFF" />
</aapt:attr>
</path>
</vector>
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M41.79,43.67Q32.39,42.10 24.57,45.55"
android:strokeWidth="4.07"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:startColor="#E8EAF0"
android:endColor="#7E9BFF"
android:startX="23"
android:startY="54"
android:endX="42"
android:endY="54" />
</aapt:attr>
</path>
<path
android:pathData="M41.16,50.56Q30.51,49.93 23.00,54.31"
android:strokeWidth="4.07"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:startColor="#E8EAF0"
android:endColor="#7E9BFF"
android:startX="23"
android:startY="54"
android:endX="42"
android:endY="54" />
</aapt:attr>
</path>
<path
android:pathData="M41.79,57.44Q33.02,58.70 26.76,63.71"
android:strokeWidth="4.07"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:startColor="#E8EAF0"
android:endColor="#7E9BFF"
android:startX="23"
android:startY="54"
android:endX="42"
android:endY="54" />
</aapt:attr>
</path>
<path
android:pathData="M49.62,66.21C47.42,71.97 44.92,73.10 41.79,75.61C46.49,74.35 50.87,71.54 54.94,66.84Z"
android:fillColor="#3D63E8" />
<path
android:pathData="M53.68,32.39H73.73A11.27,11.27 0 0,1 85,43.66V56.19A11.27,11.27 0 0,1 73.73,67.46H53.68A11.27,11.27 0 0,1 42.41,56.19V43.66A11.27,11.27 0 0,1 53.68,32.39Z">
<aapt:attr name="android:fillColor">
<gradient
android:startColor="#6E93FF"
android:endColor="#3D63E8"
android:startX="63.71"
android:startY="32.39"
android:endX="63.71"
android:endY="67.46" />
</aapt:attr>
</path>
<path
android:pathData="M50.56,51.81A13.15,13.15 0 0,1 76.86,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#FF6B6B" />
<path
android:pathData="M53.06,51.81A10.65,10.65 0 0,1 74.36,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#FFC107" />
<path
android:pathData="M55.57,51.81A8.14,8.14 0 0,1 71.85,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#4CAF50" />
<path
android:pathData="M58.07,51.81A5.64,5.64 0 0,1 69.35,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#29B6F6" />
<path
android:pathData="M61.99,51.81a1.72,1.72 0 1,0 3.44,0a1.72,1.72 0 1,0 -3.44,0z"
android:fillColor="#7C4DFF" />
</vector>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M41.79,43.67Q32.39,42.10 24.57,45.55"
android:strokeWidth="4.07"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M41.16,50.56Q30.51,49.93 23.00,54.31"
android:strokeWidth="4.07"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M41.79,57.44Q33.02,58.70 26.76,63.71"
android:strokeWidth="4.07"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M49.62,66.21C47.42,71.97 44.92,73.10 41.79,75.61C46.49,74.35 50.87,71.54 54.94,66.84Z"
android:fillColor="#FFFFFF" />
<path
android:pathData="M53.68,32.39H73.73A11.27,11.27 0 0,1 85,43.66V56.19A11.27,11.27 0 0,1 73.73,67.46H53.68A11.27,11.27 0 0,1 42.41,56.19V43.66A11.27,11.27 0 0,1 53.68,32.39Z"
android:strokeWidth="3"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M50.56,51.81A13.15,13.15 0 0,1 76.86,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M53.06,51.81A10.65,10.65 0 0,1 74.36,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M55.57,51.81A8.14,8.14 0 0,1 71.85,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M58.07,51.81A5.64,5.64 0 0,1 69.35,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M61.99,51.81a1.72,1.72 0 1,0 3.44,0a1.72,1.72 0 1,0 -3.44,0z"
android:fillColor="#FFFFFF" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
+14
View File
@@ -28,6 +28,16 @@ compose.desktop {
}
}
// M9: KCEF (JCEF/Chromium) for HTML artifact previews needs AWT reflection
// access on modern JDKs. Applied to the `run` task (JavaExec) and, below, to
// the jpackage `--java-options`.
afterEvaluate {
tasks.withType<JavaExec> {
jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
}
}
// ── M6: native packaging (docs/11 §11.4) ─────────────────────────────────
// `./gradlew :desktopApp:jpackage` builds a self-contained app image under
// build/jpackage (JRE bundled). `./gradlew :desktopApp:jpackage
@@ -75,7 +85,11 @@ tasks.register<Exec>("jpackage") {
"--input", inputDir.absolutePath,
"--main-jar", "desktopApp-all.jar",
"--main-class", "iris.desktop.MainKt",
"--icon", file("src/main/resources/icon.png").absolutePath,
"--dest", destDir.absolutePath,
"--java-options", "-Xmx1g",
// M9: KCEF (JCEF) AWT reflection access (see the JavaExec block above).
"--java-options", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
"--java-options", "--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
)
}
@@ -21,6 +21,7 @@ import iris.platform.DesktopBridge
import iris.platform.DesktopSecureStore
import java.awt.Color
import java.awt.Graphics2D
import javax.imageio.ImageIO
import java.awt.RenderingHints
import java.awt.SystemTray
import java.awt.event.WindowEvent
@@ -45,6 +46,10 @@ import kotlinx.serialization.json.jsonPrimitive
*/
private val windowJson = File(System.getProperty("user.home"), ".iris/window.json")
// Window/taskbar icon (src/main/resources/icon.png).
private class IrisDesktop
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
private const val TRAY_OFFLINE = 0xFF9E9E9E.toInt()
private const val TRAY_CONNECTING = 0xFFFFC107.toInt()
@@ -107,6 +112,7 @@ fun main() {
}
Window(
icon = BitmapPainter(windowIcon),
onCloseRequest = {
if (closeToTray.get()) {
composeWindow?.isVisible = false
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+48
View File
@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#262B3A"/>
<stop offset="1" stop-color="#14171F"/>
</linearGradient>
<linearGradient id="bubble" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#6E93FF"/>
<stop offset="1" stop-color="#3D63E8"/>
</linearGradient>
<linearGradient id="wing" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#E8EAF0"/>
<stop offset="1" stop-color="#7E9BFF"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#4F7CFF" stop-opacity="0.22"/>
<stop offset="1" stop-color="#4F7CFF" stop-opacity="0"/>
</radialGradient>
<clipPath id="bgclip">
<rect x="16" y="16" width="480" height="480" rx="112"/>
</clipPath>
</defs>
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
<g clip-path="url(#bgclip)">
<circle cx="312" cy="240" r="190" fill="url(#glow)"/>
</g>
<g fill="none" stroke="url(#wing)" stroke-width="26" stroke-linecap="round">
<path d="M 172 192 Q 112 182 62 204"/>
<path d="M 168 236 Q 100 232 52 260"/>
<path d="M 172 280 Q 116 288 76 320"/>
</g>
<path d="M 222 336 C 208 360 192 380 172 396 C 202 388 230 370 256 340 Z" fill="#3D63E8"/>
<rect x="176" y="120" width="272" height="224" rx="72" fill="url(#bubble)"/>
<g fill="none" stroke-width="12" stroke-linecap="round">
<path d="M 228 244 A 84 84 0 0 1 396 244" stroke="#FF6B6B"/>
<path d="M 244 244 A 68 68 0 0 1 380 244" stroke="#FFC107"/>
<path d="M 260 244 A 52 52 0 0 1 364 244" stroke="#4CAF50"/>
<path d="M 276 244 A 36 36 0 0 1 348 244" stroke="#29B6F6"/>
</g>
<circle cx="312" cy="244" r="11" fill="#7C4DFF"/>
<rect x="16" y="16" width="480" height="480" rx="112" fill="none" stroke="#2A2E3B" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+62
View File
@@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#262B3A"/>
<stop offset="1" stop-color="#14171F"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#4F7CFF" stop-opacity="0.22"/>
<stop offset="1" stop-color="#4F7CFF" stop-opacity="0"/>
</radialGradient>
<linearGradient id="wingL" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#E8EAF0"/>
<stop offset="1" stop-color="#7E9BFF"/>
</linearGradient>
<linearGradient id="wingR" x1="1" y1="0" x2="0" y2="0">
<stop offset="0" stop-color="#E8EAF0"/>
<stop offset="1" stop-color="#7E9BFF"/>
</linearGradient>
<clipPath id="bgclip">
<rect x="16" y="16" width="480" height="480" rx="112"/>
</clipPath>
<clipPath id="eye">
<path d="M 96 272 Q 256 148 416 272 Q 256 396 96 272 Z"/>
</clipPath>
</defs>
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
<g clip-path="url(#bgclip)">
<circle cx="256" cy="272" r="200" fill="url(#glow)"/>
</g>
<g fill="none" stroke-width="14" stroke-linecap="round">
<g stroke="url(#wingL)">
<path d="M 88 248 Q 56 236 32 244"/>
<path d="M 84 272 Q 52 268 28 280"/>
<path d="M 88 296 Q 56 300 36 314"/>
</g>
<g stroke="url(#wingR)">
<path d="M 424 248 Q 456 236 480 244"/>
<path d="M 428 272 Q 460 268 484 280"/>
<path d="M 424 296 Q 456 300 476 314"/>
</g>
</g>
<g clip-path="url(#eye)">
<path d="M 256 272 L 256 184 A 88 88 0 0 1 332 228 Z" fill="#7C4DFF"/>
<path d="M 256 272 L 332 228 A 88 88 0 0 1 332 316 Z" fill="#4F7CFF"/>
<path d="M 256 272 L 332 316 A 88 88 0 0 1 256 360 Z" fill="#29B6F6"/>
<path d="M 256 272 L 256 360 A 88 88 0 0 1 180 316 Z" fill="#4CAF50"/>
<path d="M 256 272 L 180 316 A 88 88 0 0 1 180 228 Z" fill="#FFC107"/>
<path d="M 256 272 L 180 228 A 88 88 0 0 1 256 184 Z" fill="#FF6B6B"/>
<circle cx="256" cy="272" r="38" fill="#10131C"/>
<circle cx="272" cy="254" r="10" fill="#FFFFFF" opacity="0.9"/>
<circle cx="242" cy="290" r="5" fill="#FFFFFF" opacity="0.35"/>
</g>
<path d="M 96 272 Q 256 148 416 272 Q 256 396 96 272 Z" fill="none" stroke="#E8EAF0" stroke-width="12" stroke-linejoin="round"/>
<path d="M 262 372 L 238 414 L 254 412 L 244 444 L 274 398 L 257 400 Z" fill="#FFC107"/>
<rect x="16" y="16" width="480" height="480" rx="112" fill="none" stroke="#2A2E3B" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+40
View File
@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#262B3A"/>
<stop offset="1" stop-color="#14171F"/>
</linearGradient>
<linearGradient id="planeTop" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8FB0FF"/>
<stop offset="1" stop-color="#5B86FF"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#4F7CFF" stop-opacity="0.22"/>
<stop offset="1" stop-color="#4F7CFF" stop-opacity="0"/>
</radialGradient>
<clipPath id="bgclip">
<rect x="16" y="16" width="480" height="480" rx="112"/>
</clipPath>
</defs>
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
<g clip-path="url(#bgclip)">
<circle cx="320" cy="220" r="190" fill="url(#glow)"/>
</g>
<g fill="none" stroke-width="16" stroke-linecap="round">
<path d="M 60 368 Q 172 348 258 284" stroke="#FF6B6B"/>
<path d="M 84 396 Q 196 376 284 312" stroke="#FFC107"/>
<path d="M 108 424 Q 220 404 308 340" stroke="#4CAF50"/>
<path d="M 132 452 Q 244 432 332 368" stroke="#29B6F6"/>
</g>
<path d="M 396 116 L 216 212 L 296 258 Z" fill="url(#planeTop)"/>
<path d="M 396 116 L 296 258 L 316 326 Z" fill="#4F7CFF"/>
<path d="M 216 212 L 296 258 L 316 326 Z" fill="#2A3550"/>
<path d="M 436 78 L 441 89 L 452 94 L 441 99 L 436 110 L 431 99 L 420 94 L 431 89 Z" fill="#E8EAF0" opacity="0.9"/>
<path d="M 458 143 L 461 149 L 467 152 L 461 155 L 458 161 L 455 155 L 449 152 L 455 149 Z" fill="#E8EAF0" opacity="0.6"/>
<rect x="16" y="16" width="480" height="480" rx="112" fill="none" stroke="#2A2E3B" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

+41
View File
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#262B3A"/>
<stop offset="1" stop-color="#14171F"/>
</linearGradient>
<linearGradient id="bubble" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#6E93FF"/>
<stop offset="1" stop-color="#3D63E8"/>
</linearGradient>
<linearGradient id="wing" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#E8EAF0"/>
<stop offset="1" stop-color="#7E9BFF"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#4F7CFF" stop-opacity="0.22"/>
<stop offset="1" stop-color="#4F7CFF" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<circle cx="308.92" cy="240.64" r="162.17" fill="url(#glow)"/>
<g fill="none" stroke="url(#wing)" stroke-width="22.19" stroke-linecap="round">
<path d="M 189.42 199.67 Q 138.21 191.13 95.53 209.91"/>
<path d="M 186.01 237.22 Q 127.97 233.81 87.01 257.71"/>
<path d="M 189.42 274.78 Q 141.63 281.61 107.48 308.92"/>
</g>
<path d="M 232.10 322.58 C 220.15 343.06 206.49 360.13 189.42 373.79 C 215.03 366.96 238.93 351.60 261.12 325.99 Z" fill="#3D63E8"/>
<rect x="192.84" y="138.21" width="232.16" height="191.19" rx="61.45" fill="url(#bubble)"/>
<g fill="none" stroke-width="10.24" stroke-linecap="round">
<path d="M 237.22 244.05 A 71.70 71.70 0 0 1 380.62 244.05" stroke="#FF6B6B"/>
<path d="M 250.88 244.05 A 58.04 58.04 0 0 1 366.96 244.05" stroke="#FFC107"/>
<path d="M 264.54 244.05 A 44.38 44.38 0 0 1 353.30 244.05" stroke="#4CAF50"/>
<path d="M 278.19 244.05 A 30.73 30.73 0 0 1 339.65 244.05" stroke="#29B6F6"/>
</g>
<circle cx="308.92" cy="244.05" r="9.39" fill="#7C4DFF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+2
View File
@@ -10,6 +10,8 @@ dependencyResolutionManagement {
repositories {
mavenCentral()
google()
// KCEF (desktop WebView backend) pulls jogamp artifacts from here.
maven("https://jogamp.org/deployment/maven")
}
}
+30
View File
@@ -10,6 +10,15 @@ val composeVersion = "1.7.3"
val coroutinesVersion = "1.9.0"
val serializationVersion = "1.7.3"
val okhttpVersion = "4.12.0"
// Pinned to 1.9.40: the last release built against Compose 1.7.x (1.7.1);
// 2.0.x moves to Compose 1.8+. HTML artifact previews (M9): Android uses the
// platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use).
val webviewVersion = "1.9.40"
val kcefVersion = "2024.04.20.3"
// Pinned to 0.33.0: the last release built against Compose 1.7.x / Kotlin 2.1.x
// (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore,
// and the -code module for language-aware syntax highlighting (Highlights).
val markdownVersion = "0.33.0"
kotlin {
androidTarget()
@@ -27,11 +36,32 @@ kotlin {
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
implementation("org.jetbrains.compose.material3:material3:$composeVersion")
implementation("org.jetbrains.compose.ui:ui:$composeVersion")
// Full Material icon set (Icons.Filled.ContentCopy for the code
// copy buttons); -core only ships a small subset.
implementation("org.jetbrains.compose.material:material-icons-extended:$composeVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
// OkHttp is a JVM library; both targets here are JVM-based
// (androidTarget + jvm("desktop")), so it resolves on both.
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
// Markdown rendering for chat bubbles (M8): core parser/renderer,
// Material 3 themed defaults, and the -code module for syntax
// highlighting of fenced code blocks (Highlights).
implementation("com.mikepenz:multiplatform-markdown-renderer:$markdownVersion")
implementation("com.mikepenz:multiplatform-markdown-renderer-m3:$markdownVersion")
implementation("com.mikepenz:multiplatform-markdown-renderer-code:$markdownVersion")
// M9: HTML artifact previews — WebView composable (platform WebView
// on Android, KCEF/JCEF on desktop).
implementation("io.github.kevinnzou:compose-webview-multiplatform:$webviewVersion")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
// M9: KCEF (JCEF/Chromium) for desktop HTML artifact previews. The
// webview library exposes it transitively, but we reference KCEF
// directly (init + progress) so declare it explicitly.
desktopMain.dependencies {
implementation("dev.datlag:kcef:$kcefVersion")
}
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
androidMain.dependencies {
@@ -82,6 +82,31 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
}
}
@Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
onPicked(null)
return@rememberLauncherForActivityResult
}
try {
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
val name = queryDisplayName(context, uri) ?: "background"
// Persistent (filesDir), unlike the upload staging dir in cacheDir.
val dir = File(context.filesDir, "theme").apply { mkdirs() }
val target = File(dir, "background${extForMime(mime)}")
context.contentResolver.openInputStream(uri)!!.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
onPicked(PickedFile(target.absolutePath, name, mime, target.length()))
} catch (e: Exception) {
onPicked(null)
}
}
Button(onClick = { launcher.launch("image/*") }) { Text("Choose image…") }
}
private fun queryDisplayName(context: Context, uri: Uri): String? {
context.contentResolver.query(uri, null, null, null, null)?.use { c ->
val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
@@ -102,6 +127,49 @@ actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Disp
}
}
actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) {
try {
// inJustDecodeBounds fills [bounds] and returns null by design — only
// the outWidth/outHeight are meaningful here.
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
BitmapFactory.decodeFile(path, opts)?.asImageBitmap()
} catch (e: Exception) {
null
}
}
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bitmap = BitmapFactory.decodeFile(path, opts) ?: return@withContext null
val stream = java.io.ByteArrayOutputStream()
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream)
bitmap.recycle()
android.util.Base64.encodeToString(stream.toByteArray(), android.util.Base64.NO_WRAP)
} catch (e: Exception) {
null
}
}
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
try {
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
} catch (e: Exception) {
null
}
}
@Composable
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
val path = media.localPath
@@ -6,6 +6,7 @@ import android.os.Build
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import iris.data.SecureStore
import iris.ui.theme.UserTheme
import java.util.UUID
/**
@@ -97,6 +98,42 @@ class AndroidSecureStore(context: Context) : SecureStore {
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply()
override var threadsEnabled: Boolean
get() = prefs.getBoolean(KEY_THREADS_ENABLED, false)
set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply()
override var toolDetail: String
get() = prefs.getString(KEY_TOOL_DETAIL, "truncated").orEmpty().ifBlank { "truncated" }
set(value) = prefs.edit().putString(KEY_TOOL_DETAIL, value).apply()
override var streamingEnabled: Boolean
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
override var reasoningAutoCollapse: Boolean
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
override var userBubbleColor: Int
get() = prefs.getInt(KEY_USER_BUBBLE_COLOR, UserTheme.DEFAULT_USER_BUBBLE)
set(value) = prefs.edit().putInt(KEY_USER_BUBBLE_COLOR, value).apply()
override var agentBubbleColor: Int
get() = prefs.getInt(KEY_AGENT_BUBBLE_COLOR, UserTheme.DEFAULT_AGENT_BUBBLE)
set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply()
override var backgroundMode: String
get() = prefs.getString(KEY_BACKGROUND_MODE, "color").orEmpty().ifBlank { "color" }
set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply()
override var backgroundColor: Int
get() = prefs.getInt(KEY_BACKGROUND_COLOR, UserTheme.DEFAULT_BACKGROUND)
set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply()
override var backgroundImagePath: String
get() = prefs.getString(KEY_BACKGROUND_IMAGE_PATH, "").orEmpty()
set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply()
override fun savePairing(url: String, token: String) {
serverUrl = url
this.token = token
@@ -116,5 +153,14 @@ class AndroidSecureStore(context: Context) : SecureStore {
const val KEY_FCM_TOKEN = "fcm_token"
const val KEY_NTFY_TOPIC = "ntfy_topic"
const val KEY_NTFY_SERVER = "ntfy_server"
const val KEY_THREADS_ENABLED = "threads_enabled"
const val KEY_TOOL_DETAIL = "tool_detail"
const val KEY_STREAMING_ENABLED = "streaming_enabled"
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
const val KEY_BACKGROUND_MODE = "background_mode"
const val KEY_BACKGROUND_COLOR = "background_color"
const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path"
}
}
@@ -0,0 +1,39 @@
package iris.platform
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import com.multiplatform.webview.web.WebView
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
/**
* M9: HTML artifact preview on Android — the platform WebView (JavaScript
* enabled by default in the library's WebSettings).
*/
@Composable
actual fun PlatformWebView(html: String, modifier: Modifier) {
val state = rememberWebViewStateWithHTMLData(data = html)
WebView(state, modifier = modifier)
}
@Composable
actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val currentOnBack = rememberUpdatedState(onBack)
DisposableEffect(dispatcher) {
if (dispatcher != null) {
val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
currentOnBack.value()
}
}
dispatcher.addCallback(callback)
onDispose { callback.remove() }
}
onDispose { }
}
return this
}
@@ -0,0 +1,5 @@
package iris.ui
import androidx.compose.ui.Modifier
actual fun Modifier.rightClick(onRightClick: () -> Unit): Modifier = this
@@ -1,24 +1,29 @@
package iris
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import iris.data.SecureStore
import iris.net.GatewayClient
import iris.platform.setActiveController
import iris.state.IrisController
import iris.ui.ArtifactPreviewOverlay
import iris.ui.screens.ChatScreen
import iris.ui.screens.ConnectScreen
import iris.ui.screens.ConnectingScreen
import iris.ui.theme.IrisColors
import iris.ui.theme.IrisTheme
import iris.util.hostFromUrl
import iris.ui.theme.LocalUserTheme
import iris.ui.theme.rememberBackgroundImage
/**
* Root composable shared by the Android and Desktop shells.
@@ -49,9 +54,33 @@ fun IrisApp(
}
}
val state by controller.client.state.collectAsState()
val theme by controller.theme.collectAsState()
IrisTheme {
Surface(modifier = Modifier.background(IrisColors.background)) {
CompositionLocalProvider(LocalUserTheme provides theme) {
// The color goes through Surface's `color` parameter: a
// Modifier.background on the Surface would be painted *under* the
// Surface's own colorScheme.surface fill and never show through.
// contentColor is pinned to the app's light text color — Surface
// would otherwise derive it from the user's background (black
// text on a light background), which would also darken text that
// sits on the app's own dark panels.
Surface(
modifier = Modifier.fillMaxSize(),
color = theme.background,
contentColor = IrisColors.onBackground,
) {
val bgImage = rememberBackgroundImage(theme)
Box(modifier = Modifier.fillMaxSize()) {
val bmp = bgImage
if (bmp != null) {
Image(
bitmap = bmp,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
val s = state
when (s) {
GatewayClient.State.Disconnected ->
@@ -63,11 +92,16 @@ fun IrisApp(
prefillToken = store.token,
initialError = "Pairing rejected: ${s.message}",
)
// M7: initial connect in flight — dedicated screen, not an empty chat.
GatewayClient.State.Connecting ->
ConnectingScreen(hostFromUrl(store.serverUrl))
// Connecting / Reconnecting / Connected all render the chat; the header
// status bubble + connection banner show the link state
// without blocking the view (M7's full-screen spinner is gone).
else -> ChatScreen(controller)
}
// M9: full-screen HTML artifact preview (opened from an
// artifact card in the chat); covers everything while open.
ArtifactPreviewOverlay()
}
}
}
}
}
@@ -54,6 +54,7 @@ class ChannelStore {
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
list.sortedWith(
compareByDescending<ChannelInfo> { it.isDefault }
.thenByDescending { it.favorite }
.thenBy { it.kind != "thread" }
.thenBy { it.name.lowercase() },
)
@@ -22,6 +22,7 @@ import iris.protocol.TYPE_TOOL_START
import iris.protocol.ToolEndPayload
import iris.protocol.ToolProgressPayload
import iris.protocol.ToolStartPayload
import iris.util.nowMillis
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -50,7 +51,9 @@ enum class MsgStatus {
Failed, // send failed (error frame); tap the bubble to retry
}
/** A chat message (user / assistant / commentary / streaming bubble). */
/** A chat message (user / assistant / commentary / streaming bubble).
* [isSystem] marks a locally generated, centered notice (gateway restart /
* online) — not a user or agent turn, not selectable or deletable. */
data class MessageItem(
override val id: String,
val role: String,
@@ -64,6 +67,7 @@ data class MessageItem(
val model: String? = null,
val tokens: Int? = null,
val media: List<MediaItem> = emptyList(),
val isSystem: Boolean = false,
) : ChatItem
/**
@@ -106,6 +110,12 @@ class ChatStore {
private var localSeq = 0
private var toolSeq = 0
/** When false, `message.start`/`message.update` frames are ignored and each
* reply materializes as a single final message on `message.stop`
* (Settings → "Streaming"). The gateway keeps streaming (other devices
* may want it); this is a per-device display preference. */
var streamingEnabled: Boolean = true
companion object {
const val DEFAULT_LANE = "android:default"
@@ -153,6 +163,17 @@ class ChatStore {
return id
}
/** Append a locally generated, centered system notice (gateway restart /
* online) to [lane]. Not a user or agent turn: it is not selectable,
* deletable, or echoed to the server. */
fun addSystemMessage(lane: String, text: String) {
localSeq++
val id = "sys_$localSeq"
updateLane(lane) {
it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true)
}
}
// ── Frame reconciliation ──────────────────────────────────────────────
/** Reconcile a server frame into the cache (routed by chat/thread lane). */
@@ -176,6 +197,10 @@ class ChatStore {
private fun onMessage(lane: String, frame: Frame) {
val p = frame.payloadAs<MessagePayload>() ?: return
// Auto-threaded send: the gateway created a fresh thread for this
// message; the optimistic bubble is still in the parent flat lane.
// Relocate it so the echo (below) lands in the thread lane.
if (p.role == ROLE_USER) relocatePendingToThreadLane(lane, p)
updateLane(lane) { list ->
val byId = list.indexOfFirst { it.id == p.messageId }
if (byId >= 0) {
@@ -222,6 +247,27 @@ class ChatStore {
}
}
/**
* Remove the matching optimistic user bubble from the parent flat lane of
* [lane] (an auto-created thread). The gateway moved the message into the
* fresh thread, so the pending bubble must follow it; the echo then replaces
* it in the thread lane. No-op when [lane] is not a thread lane or no
* pending bubble with the same text exists in the flat lane.
*/
private fun relocatePendingToThreadLane(lane: String, p: MessagePayload) {
val (chatId, threadId) = parseLane(lane)
if (threadId == null) return
val flatLane = chatId
val map = _lanes.value.toMutableMap()
val flatList = map[flatLane].orEmpty()
val idx = flatList.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
if (idx < 0) return
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
_lanes.value = map
}
/** Merge server media refs into existing items, keeping local paths. */
private fun mergeMedia(existing: List<MediaItem>, incoming: List<MediaRef>): List<MediaItem> {
if (incoming.isEmpty()) return existing
@@ -239,6 +285,7 @@ class ChatStore {
// ── message.start (open a live streaming bubble) ──────────────────────
private fun onMessageStart(lane: String, frame: Frame) {
if (!streamingEnabled) return
val p = frame.payloadAs<MessageStartPayload>() ?: return
updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list
@@ -336,6 +383,14 @@ class ChatStore {
private fun onCommentary(lane: String, frame: Frame) {
val p = frame.payloadAs<CommentaryPayload>() ?: return
// Gateway lifecycle notices (restart / shutdown / online) are rendered
// as a centered system notice by the controller, on the down/up state
// transition. The server also emits the "restarting" notice as a
// commentary frame (and the sync catch-up can replay it *after* the
// local "online" notice), so drop it here: the app is the source of
// truth for these notices, which keeps the order (restarting → online)
// and prevents duplicates.
if (isGatewayLifecycleNotice(p.text)) return
updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list
else list + MessageItem(
@@ -345,6 +400,14 @@ class ChatStore {
}
}
/** True for hermes gateway lifecycle notices (same wording on all apps). */
private fun isGatewayLifecycleNotice(text: String): Boolean {
val t = text.trim()
return t.contains("Gateway restarting") ||
t.contains("Gateway shutting down") ||
t.contains("Gateway online")
}
// ── M4: media.offer (agent produced media; pull it) ─────────────────────
/**
@@ -412,6 +475,51 @@ class ChatStore {
if (changed) _lanes.value = map
}
/**
* Remove messages by id from every lane (a `message.deleted` frame). The
* server is authoritative: the frame carries no lane, and a message id is
* unique, so scanning all lanes is both safe and idempotent (a no-op when the
* id is absent, e.g. a local-only id another device never had).
*/
fun removeMessages(messageIds: Set<String>) {
if (messageIds.isEmpty()) return
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.filterNot { it.id in messageIds }
if (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
}
/**
* Finalize in-flight items after the gateway goes away (restart / network
* drop): open tool cards are closed as interrupted and live streaming
* bubbles are stopped, so nothing spins or streams forever. The in-flight
* turn is gone server-side, so its terminal frames (tool.end /
* message.stop) will never arrive to close them.
*/
fun finalizeInterrupted() {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.map { item ->
when (item) {
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item
is MessageItem -> if (item.streaming) item.copy(streaming = false) else item
}
}
if (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
}
/** M7: mark all pending user messages as failed (gateway error frame). */
fun failPending() {
val map = _lanes.value.toMutableMap()
@@ -441,6 +549,27 @@ class ChatStore {
}
}
/**
* Load a history page into [lane] (oldest → newest). The history is the
* authoritative full list of final messages for the lane; it replaces the
* lane's final messages and preserves non-final items (tool cards, live
* streaming bubbles, commentary) that are not part of the history. Used to
* restore the view on first open of a chat / after a process death, where
* the in-memory store is empty and the `sync` delta does not cover older
* messages.
*/
fun loadHistory(lane: String, messages: List<MessageItem>) {
updateLane(lane) { list ->
val historyIds = messages.map { it.id }.toSet()
val preserved = list.filter { item ->
item !is MessageItem || item.id !in historyIds
}
(messages + preserved).sortedBy { item ->
(item as? MessageItem)?.ts?.takeIf { it > 0 } ?: Long.MAX_VALUE
}
}
}
fun clear() {
_lanes.value = emptyMap()
}
@@ -30,6 +30,33 @@ interface SecureStore {
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
var ntfyServer: String
/** UI setting: show threads (topics) in the chat view. */
var threadsEnabled: Boolean
/** UI setting: tool verbosity — "everything" / "truncated" / "nothing". */
var toolDetail: String
/** UI setting: stream assistant replies live (token by token). */
var streamingEnabled: Boolean
/** UI setting: auto-collapse long reasoning blocks in the chat view. */
var reasoningAutoCollapse: Boolean
/** UI setting: user bubble color (ARGB int). */
var userBubbleColor: Int
/** UI setting: agent bubble color (ARGB int). */
var agentBubbleColor: Int
/** UI setting: background mode — "color" or "image". */
var backgroundMode: String
/** UI setting: background color (ARGB int). */
var backgroundColor: Int
/** UI setting: background image path (empty when unused). */
var backgroundImagePath: String
fun savePairing(url: String, token: String)
fun clear()
}
@@ -92,6 +92,10 @@ class GatewayClient(
private var socket: WebSocket? = null
private var nextRequestId = 1
private var attempt = 0
// True once a connection has been established this session; reset by
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a
// drop) so the UI can show the right status without a blocking screen.
private var hasConnected = false
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
@@ -118,6 +122,7 @@ class GatewayClient(
fun start() {
if (connectJob?.isActive == true) return
attempt = 0
hasConnected = false
connectJob = scope.launch { connectLoop() }
}
@@ -144,7 +149,7 @@ class GatewayClient(
_state.value = State.Disconnected
return
}
_state.value = if (attempt == 0) State.Connecting else State.Reconnecting
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
val dial = dial(url, token)
when (val result = dial.result) {
is DialResult.AuthFailed -> {
@@ -153,6 +158,7 @@ class GatewayClient(
return
}
DialResult.Connected -> {
hasConnected = true
attempt = 0
lastLiveness = TimeSource.Monotonic.markNow()
dial.closed.await()
@@ -289,16 +295,19 @@ class GatewayClient(
// ── Outbound ──────────────────────────────────────────────────────────
/** Send a text message (fire-and-forget; the server echoes it back).
* M4: [mediaRefs] reference completed uploads (media.upload.ack refs). */
* M4: [mediaRefs] reference completed uploads (media.upload.ack refs).
* [autoThread] asks the gateway to mint a fresh thread for the message
* (auto-threading, docs/06 §6.3). */
fun sendMessage(
chatId: String,
text: String,
threadId: String? = null,
mediaRefs: List<String> = emptyList(),
autoThread: Boolean = false,
) {
val ws = socket ?: return
val id = nextRequestId++
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs).toWire())
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire())
}
// ── M4: media upload / pull ───────────────────────────────────────────
@@ -20,12 +20,33 @@ data class PickedFile(
@Composable
expect fun MediaFilePicker(onPicked: (PickedFile?) -> Unit)
/**
* Image-only file picker (SAF on Android, FileChooser on desktop). The picked
* image is copied into the app's persistent storage; [onPicked] receives the
* staged file, or null when the user cancels.
*/
@Composable
expect fun ImageFilePicker(onPicked: (PickedFile?) -> Unit)
/** Base directory for the media cache (app-specific cache dir). */
expect fun mediaCacheBaseDir(): String
/** Load an image from a local path for display. Null on failure. */
expect suspend fun loadMediaImage(path: String): ImageBitmap?
/** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */
expect suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap?
/**
* Encode a local image as a base64 PNG, downscaled to at most [maxSize] px on
* the longest edge. Null on failure. Used for channel icons (stored on the
* gateway, synced across devices).
*/
expect suspend fun encodeImageAsBase64(path: String, maxSize: Int): String?
/** Decode a base64-encoded image (PNG/JPEG) into an [ImageBitmap]. Null on failure. */
expect suspend fun decodeBase64Image(base64: String): ImageBitmap?
/** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
@Composable
expect fun MediaPlayerView(media: MediaItem, modifier: Modifier)
@@ -0,0 +1,18 @@
package iris.platform
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* M9: renders [html] in an in-app WebView with JavaScript enabled.
*
* Platform actuals: Android → the platform WebView; desktop → JCEF (KCEF),
* whose Chromium binary is downloaded on first use (the caller shows a
* progress indicator until it is ready).
*/
@Composable
expect fun PlatformWebView(html: String, modifier: Modifier)
/** Consumes the system back button (Android) while applied; no-op elsewhere. */
@Composable
expect fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier
@@ -3,6 +3,7 @@ package iris.protocol
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
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
@@ -41,6 +42,10 @@ const val TYPE_TYPING = "typing"
const val TYPE_MESSAGE_START = "message.start"
const val TYPE_MESSAGE_UPDATE = "message.update"
const val TYPE_MESSAGE_STOP = "message.stop"
// Message deletion (app requests; broadcast to all devices)
const val TYPE_MESSAGE_DELETE = "message.delete"
const val TYPE_MESSAGE_DELETED = "message.deleted"
const val TYPE_TOOL_START = "tool.start"
const val TYPE_TOOL_PROGRESS = "tool.progress"
const val TYPE_TOOL_END = "tool.end"
@@ -64,6 +69,8 @@ const val TYPE_STATUS = "status"
const val TYPE_CHANNEL_CREATE = "channel.create"
const val TYPE_CHANNEL_RENAME = "channel.rename"
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
const val TYPE_CHANNEL_ICON = "channel.icon"
const val TYPE_CHANNEL_DELETE = "channel.delete"
const val TYPE_CHANNEL_CREATED = "channel.created"
const val TYPE_CHANNEL_RENAMED = "channel.renamed"
@@ -73,6 +80,7 @@ const val TYPE_SEARCH = "search"
const val TYPE_SEARCH_RESULTS = "search.results"
const val TYPE_SYNC = "sync"
const val TYPE_SYNC_DONE = "sync.done"
const val TYPE_HISTORY = "history"
// ── Error codes ─────────────────────────────────────────────────────────
@@ -149,6 +157,17 @@ data class ChannelInfo(
@SerialName("is_default") val isDefault: Boolean = false,
@SerialName("parent_chat_id") val parentChatId: String? = null,
val archived: Boolean = false,
/** Gateway minted this thread for an incoming message (auto-threading);
* the name is a derived title, upgraded by the LLM via channel.renamed. */
val auto: Boolean = false,
/** Cosmetic favorite flag (sorts to the top of the channel list). */
val favorite: Boolean = false,
/** Cosmetic icon: a base64-encoded image (PNG/JPEG), or null for the
* auto-generated letter avatar. */
val icon: String? = null,
/** Cosmetic avatar color override ("#RRGGBB"), or null for the
* auto-generated name-hash color. */
val color: String? = null,
)
@Serializable
@@ -289,6 +308,9 @@ data class MessageSendPayload(
val text: String,
@SerialName("reply_to") val replyTo: String? = null,
@SerialName("media_refs") val mediaRefs: List<String> = emptyList(),
/** Ask the gateway to mint a fresh thread for this message (auto-
* threading; only honored in a channel's flat lane, docs/06 §6.3). */
@SerialName("auto_thread") val autoThread: Boolean = false,
)
// ── typing / error / ping ───────────────────────────────────────────────
@@ -314,6 +336,17 @@ data class ChannelCreatePayload(
@Serializable
data class ChannelRenamePayload(val name: String)
@Serializable
data class ChannelFavoritePayload(val on: Boolean)
@Serializable
data class ChannelIconPayload(
/** base64-encoded image, or null to clear the icon. */
val icon: String? = null,
/** "#RRGGBB" avatar color, or null to clear it. */
val color: String? = null,
)
@Serializable
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList())
@@ -356,6 +389,36 @@ data class SyncPayload(val cursor: Long)
@Serializable
data class SyncDonePayload(val cursor: Long)
// ── history (full message history for a chat/thread) ────────────────────
/** A final message in a history page (user echo / assistant reply). */
@Serializable
data class HistoryMessage(
@SerialName("message_id") val messageId: String,
val role: String,
val text: String,
val reasoning: String? = null,
val model: String? = null,
val tokens: Int? = null,
val ts: Long? = null,
val media: List<MediaRef> = emptyList(),
)
@Serializable
data class HistoryPayload(
val messages: List<HistoryMessage> = emptyList(),
@SerialName("has_more") val hasMore: Boolean = false,
@SerialName("oldest_message_id") val oldestMessageId: String? = null,
)
// ── message deletion ──────────────────────────────────────────────────────
/** The message(s) removed from a chat/thread (response + broadcast). */
@Serializable
data class MessageDeletedPayload(
@SerialName("message_ids") val messageIds: List<String> = emptyList(),
)
// ── M5: push / notifications ────────────────────────────────────────────
/** Notification kinds (mirror of protocol.NOTIF_*). */
@@ -424,6 +487,7 @@ fun messageSendFrame(
text: String,
threadId: String? = null,
mediaRefs: List<String> = emptyList(),
autoThread: Boolean = false,
): Frame =
Frame(
id = id,
@@ -432,7 +496,7 @@ fun messageSendFrame(
threadId = threadId,
payload = IrisJson.instance.encodeToJsonElement(
MessageSendPayload.serializer(),
MessageSendPayload(text = text, mediaRefs = mediaRefs),
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
),
)
@@ -465,6 +529,28 @@ fun channelRenameFrame(id: Int, chatId: String, name: String): Frame =
fun channelSetDefaultFrame(id: Int, chatId: String): Frame =
Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
fun channelFavoriteFrame(id: Int, chatId: String, on: Boolean): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_FAVORITE,
chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement(
ChannelFavoritePayload.serializer(),
ChannelFavoritePayload(on = on),
),
)
fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_ICON,
chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement(
ChannelIconPayload.serializer(),
ChannelIconPayload(icon = icon, color = color),
),
)
fun channelDeleteFrame(id: Int, chatId: String): Frame =
Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
@@ -499,6 +585,45 @@ fun syncFrame(id: Int, cursor: Long): Frame =
),
)
/** Request a page of full message history for a chat/thread (initial open /
* restart restore). [beforeMessageId] returns the page older than that id
* (null = newest page). */
fun historyFrame(
id: Int,
chatId: String,
threadId: String? = null,
beforeMessageId: String? = null,
limit: Int = 50,
): Frame =
Frame(
id = id,
type = TYPE_HISTORY,
chatId = chatId,
threadId = threadId,
payload = buildJsonObject {
if (beforeMessageId != null) put("before_message_id", beforeMessageId)
put("limit", limit)
},
)
/** Request deletion of the given message(s) in a chat/thread. The server
* removes them from the outbox and broadcasts `message.deleted`. */
fun messageDeleteFrame(
id: Int,
chatId: String,
messageIds: List<String>,
threadId: String? = null,
): Frame =
Frame(
id = id,
type = TYPE_MESSAGE_DELETE,
chatId = chatId,
threadId = threadId,
payload = buildJsonObject {
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
},
)
// ── M4 frame builders ────────────────────────────────────────────────────
fun mediaUploadStartFrame(
@@ -13,8 +13,13 @@ import iris.platform.PickedFile
import iris.platform.isAppForeground
import iris.platform.mediaCacheBaseDir
import iris.platform.postSystemNotification
import iris.protocol.ChannelDeletedPayload
import iris.protocol.ChannelInfo
import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
import iris.protocol.HistoryMessage
import iris.protocol.HistoryPayload
import iris.protocol.MediaOfferPayload
import iris.protocol.MessageDeletedPayload
import iris.protocol.MessagePayload
import iris.protocol.MessageStopPayload
import iris.protocol.NotificationPayload
@@ -25,6 +30,7 @@ import iris.protocol.SearchResultsPayload
import iris.protocol.StatusPayload
import iris.protocol.SyncDonePayload
import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HISTORY
import iris.protocol.TYPE_NOTIFICATION
import iris.protocol.TYPE_CHANNEL_CREATED
import iris.protocol.TYPE_CHANNEL_DELETED
@@ -33,6 +39,8 @@ import iris.protocol.TYPE_CHANNEL_RENAMED
import iris.protocol.TYPE_COMMENTARY
import iris.protocol.TYPE_MEDIA_OFFER
import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_DELETE
import iris.protocol.TYPE_MESSAGE_DELETED
import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE
@@ -47,11 +55,17 @@ import iris.protocol.TYPE_TYPING
import iris.protocol.TypingPayload
import iris.protocol.channelCreateFrame
import iris.protocol.channelDeleteFrame
import iris.protocol.channelFavoriteFrame
import iris.protocol.channelIconFrame
import iris.protocol.channelListFrame
import iris.protocol.channelRenameFrame
import iris.protocol.channelSetDefaultFrame
import iris.protocol.historyFrame
import iris.protocol.messageDeleteFrame
import iris.protocol.searchFrame
import iris.protocol.syncFrame
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -80,8 +94,9 @@ class IrisController(
private val _typing = MutableStateFlow(false)
val typing: StateFlow<Boolean> = _typing.asStateFlow()
/** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely. */
private val _toolDetail = MutableStateFlow(ToolDetail.TRUNCATED)
/** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely.
* Persisted (Settings → "Tool detail"). */
private val _toolDetail = MutableStateFlow(ToolDetail.fromName(store.toolDetail))
val toolDetail: StateFlow<ToolDetail> = _toolDetail.asStateFlow()
fun cycleToolDetail() {
@@ -90,22 +105,103 @@ class IrisController(
ToolDetail.TRUNCATED -> ToolDetail.NOTHING
ToolDetail.NOTHING -> ToolDetail.EVERYTHING
}
store.toolDetail = _toolDetail.value.name.lowercase()
}
fun setToolDetail(detail: ToolDetail) {
if (detail == _toolDetail.value) return
_toolDetail.value = detail
store.toolDetail = detail.name.lowercase()
}
/** Home channel id (from hello.ack; default until then). */
private val _homeChannel = MutableStateFlow("android:default")
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
/** Lanes whose full history has been loaded this session (in-memory; reset
* on a process death, which is exactly when a reload is needed). The
* `sync` delta can seed a lane with recent frames without it being opened,
* so "lane is empty" is not a reliable first-open signal. */
private val historyLoaded = mutableSetOf<String>()
/** Gateway health state (M5: status frame; null = never received). */
private val _gatewayStatus = MutableStateFlow<String?>(null)
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
// ── M3: threads toggle (per-app for now; per-channel lands later) ─────
private val _threadsEnabled = MutableStateFlow(false)
// Persisted (Settings → "Threads").
private val _threadsEnabled = MutableStateFlow(store.threadsEnabled)
val threadsEnabled: StateFlow<Boolean> = _threadsEnabled.asStateFlow()
fun toggleThreads() {
_threadsEnabled.value = !_threadsEnabled.value
store.threadsEnabled = _threadsEnabled.value
}
// ── Streaming toggle (Settings → "Streaming") ──────────────────────────
// Persisted. When off, the app ignores message.start/update frames and
// shows each reply as a single final message (per-device display
// preference, like tool verbosity — the gateway keeps streaming).
private val _streamingEnabled = MutableStateFlow(store.streamingEnabled)
val streamingEnabled: StateFlow<Boolean> = _streamingEnabled.asStateFlow()
fun toggleStreaming() {
_streamingEnabled.value = !_streamingEnabled.value
store.streamingEnabled = _streamingEnabled.value
chat.streamingEnabled = _streamingEnabled.value
}
// ── Reasoning auto-collapse (Settings → "Reasoning") ───────────────────
// Persisted. When on, long reasoning blocks start collapsed (short ones
// stay expanded); when off, all reasoning blocks start expanded.
private val _reasoningAutoCollapse = MutableStateFlow(store.reasoningAutoCollapse)
val reasoningAutoCollapse: StateFlow<Boolean> = _reasoningAutoCollapse.asStateFlow()
fun toggleReasoningAutoCollapse() {
_reasoningAutoCollapse.value = !_reasoningAutoCollapse.value
store.reasoningAutoCollapse = _reasoningAutoCollapse.value
}
// ── Appearance (Settings → Appearance) ────────────────────────────────
// User-customizable bubble colors + background (color or image).
// Persisted as ARGB ints + an image path (see UserTheme).
private val _theme = MutableStateFlow(
UserTheme(
userBubbleColor = store.userBubbleColor,
agentBubbleColor = store.agentBubbleColor,
backgroundMode = BackgroundMode.entries.firstOrNull {
it.name.equals(store.backgroundMode, ignoreCase = true)
} ?: BackgroundMode.Color,
backgroundColor = store.backgroundColor,
backgroundImagePath = store.backgroundImagePath.ifBlank { null },
)
)
val theme: StateFlow<UserTheme> = _theme.asStateFlow()
fun setUserBubbleColor(argb: Int) = updateTheme { it.copy(userBubbleColor = argb) }
fun setAgentBubbleColor(argb: Int) = updateTheme { it.copy(agentBubbleColor = argb) }
fun setBackgroundColor(argb: Int) = updateTheme { it.copy(backgroundColor = argb) }
fun setBackgroundMode(mode: BackgroundMode) = updateTheme { it.copy(backgroundMode = mode) }
fun setBackgroundImagePath(path: String?) = updateTheme { it.copy(backgroundImagePath = path) }
/** Clears the background image and falls back to the solid color. */
fun removeBackgroundImage() =
updateTheme { it.copy(backgroundImagePath = null, backgroundMode = BackgroundMode.Color) }
fun resetTheme() = updateTheme { UserTheme() }
private fun updateTheme(transform: (UserTheme) -> UserTheme) {
val t = transform(_theme.value)
_theme.value = t
store.userBubbleColor = t.userBubbleColor
store.agentBubbleColor = t.agentBubbleColor
store.backgroundMode = t.backgroundMode.name.lowercase()
store.backgroundColor = t.backgroundColor
store.backgroundImagePath = t.backgroundImagePath.orEmpty()
}
// ── M3: search state ──────────────────────────────────────────────────
@@ -205,6 +301,7 @@ class IrisController(
}
init {
chat.streamingEnabled = _streamingEnabled.value
scope.launch {
client.events.collect { frame ->
when (frame.type) {
@@ -245,7 +342,38 @@ class IrisController(
TYPE_CHANNEL_CREATED,
TYPE_CHANNEL_RENAMED,
TYPE_CHANNEL_DELETED,
TYPE_CHANNEL_LIST -> channels.onFrame(frame)
TYPE_CHANNEL_LIST -> {
channels.onFrame(frame)
// Auto-created thread (Settings → "Threads"): the
// gateway minted it for a message in the flat lane we
// are viewing — jump into it; the user's message and
// the reply land there.
if (frame.type == TYPE_CHANNEL_CREATED) {
frame.payloadAs<ChannelInfo>()?.let { info ->
if (info.kind == "thread" && info.auto && info.parentChatId != null) {
val (curChat, curThread) = chat.parseLane(chat.currentLane.value)
if (curThread == null && curChat == info.parentChatId) {
val lane = chat.laneKey(info.parentChatId, info.chatId)
chat.setLane(lane)
historyLoaded.add(lane)
}
}
}
}
// A thread/channel we were viewing got deleted — fall back
// to a valid lane (the thread's parent, or the home channel).
if (frame.type == TYPE_CHANNEL_DELETED) {
frame.payloadAs<ChannelDeletedPayload>()?.let { p ->
val (curChat, curThread) = chat.parseLane(chat.currentLane.value)
when {
curThread == p.chatId -> openChannel(curChat)
curChat == p.chatId ->
channels.defaultChannel()?.let { openChannel(it.chatId) }
else -> Unit
}
}
}
}
TYPE_SEARCH_RESULTS -> {
frame.payloadAs<SearchResultsPayload>()?.let { _searchResults.value = it.hits }
_searching.value = false
@@ -255,6 +383,22 @@ class IrisController(
// cursor is authoritative server-side (outbox).
frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor }
}
TYPE_HISTORY -> {
// Full message history for a chat/thread (initial open /
// restart restore). `sync` only replays the outbox delta
// since the saved cursor, so after a process death the
// in-memory store is empty and older messages are not in
// the delta — history loads the full list.
frame.payloadAs<HistoryPayload>()?.let { p ->
val (chatId, threadId) =
(frame.chatId ?: return@let) to frame.threadId
val lane = chat.laneKey(chatId, threadId)
chat.loadHistory(
lane,
p.messages.map { it.toMessageItem() },
)
}
}
TYPE_NOTIFICATION -> {
frame.payloadAs<NotificationPayload>()?.let { p ->
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
@@ -273,6 +417,13 @@ class IrisController(
TYPE_READ_RECEIPT -> {
frame.payloadAs<ReadReceiptPayload>()?.let { chat.markRead(it.messageId) }
}
TYPE_MESSAGE_DELETED -> {
// A message was deleted (by this or another device).
// Idempotent: dropping an unknown id is a no-op.
frame.payloadAs<MessageDeletedPayload>()?.let {
chat.removeMessages(it.messageIds.toSet())
}
}
TYPE_STATUS -> {
frame.payloadAs<StatusPayload>()?.let { _gatewayStatus.value = it.state }
}
@@ -287,13 +438,22 @@ class IrisController(
}
}
scope.launch {
var prevState: GatewayClient.State? = null
client.state.collect { s ->
val prev = prevState
prevState = s
if (s is GatewayClient.State.Connected) {
channels.setAll(s.channels)
val home = s.channels.firstOrNull { it.isDefault }?.chatId
if (home != null) {
_homeChannel.value = home
chat.setLane(home)
// Restore the home lane after a process death: the
// in-memory store is empty and the `sync` delta does not
// cover messages older than the saved cursor, so load
// the full history. Skipped on a plain reconnect (the
// lane's history was already loaded this session).
loadHistory(home)
}
// M5: remember the ntfy server for the listener service.
if (s.caps.pushNtfyServer.isNotBlank()) {
@@ -301,6 +461,23 @@ class IrisController(
}
// M5: a deep link tapped before we were connected.
applyDeepLink()
// Gateway came back after a restart -> announce it (hermes
// routine, same icon + wording on all platforms). The core
// does not send a startup/online notice to this platform, so
// the app adds it.
if (prev is GatewayClient.State.Reconnecting) {
chat.addSystemMessage(homeChannel.value, GATEWAY_ONLINE_MSG)
}
} else if (s is GatewayClient.State.Reconnecting && prev is GatewayClient.State.Connected) {
// Gateway went away (restart / network drop): announce it
// (hermes routine, same icon + wording on all platforms) and
// close the in-flight turn's dangling tool cards / streaming
// bubble (nothing spins forever). The app is the source of
// truth for the "restarting" notice (the server's commentary
// frame is dropped in ChatStore), so the order is guaranteed:
// restarting (here) before online (on reconnect).
chat.addSystemMessage(homeChannel.value, GATEWAY_RESTARTING_MSG)
chat.finalizeInterrupted()
}
}
}
@@ -314,14 +491,19 @@ class IrisController(
// ── M3: navigation (lane switching) ───────────────────────────────────
/** Switch to a channel's flat / "General" lane. */
/** Switch to a channel's flat / "General" lane. Loads the full history on
* first open this session so the view is populated, not just the `sync`
* delta. */
fun openChannel(chatId: String) {
chat.setLane(chatId)
loadHistory(chatId)
}
/** Switch to a specific thread lane under [chatId]. */
/** Switch to a specific thread lane under [chatId]. Loads the full history
* on first open this session. */
fun openThread(chatId: String, threadId: String) {
chat.setLane(chat.laneKey(chatId, threadId))
loadHistory(chatId, threadId)
}
// ── M3: channel directory ops (server is authoritative) ───────────────
@@ -348,6 +530,17 @@ class IrisController(
client.sendFrame(channelSetDefaultFrame(0, chatId))
}
/** Toggle the cosmetic favorite flag (sorts the channel to the top). */
fun setChannelFavorite(chatId: String, on: Boolean) {
client.sendFrame(channelFavoriteFrame(0, chatId, on))
}
/** Set the channel's cosmetic icon (base64 image) and/or avatar color.
* Pass null to clear a field. */
fun setChannelIcon(chatId: String, icon: String?, color: String?) {
client.sendFrame(channelIconFrame(0, chatId, icon, color))
}
fun deleteChannel(chatId: String) {
client.sendFrame(channelDeleteFrame(0, chatId))
}
@@ -378,6 +571,19 @@ class IrisController(
client.sendFrame(syncFrame(0, cursor))
}
/** Request the full message history for a chat/thread (newest page).
* Populates the lane via the `history` response; used on first open of a
* chat and after a process death (restart) to restore the view, since the
* `sync` delta does not cover messages older than the saved cursor. */
fun loadHistory(chatId: String, threadId: String? = null) {
val lane = chat.laneKey(chatId, threadId)
if (lane in historyLoaded) return
historyLoaded.add(lane)
// Newest page, sized to restore a full working view on restart / first
// open (older pages are reachable via scroll-up pagination).
client.sendFrame(historyFrame(0, chatId, threadId, limit = 200))
}
/** Optimistic send: show immediately in the current lane, then hand to the gateway.
* M4: [attachments] (uploaded) are attached via media_refs. */
fun send(text: String, attachments: List<PendingAttachment> = emptyList()) {
@@ -394,10 +600,17 @@ class IrisController(
)
}
chat.addPending(trimmed, lane, media)
client.sendMessage(chatId, trimmed, threadId, refs)
client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId))
_attachments.value = emptyList()
}
/** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in a
* channel's flat lane gets its own fresh thread, AI-named by the gateway
* (Telegram topic-mode workflow). Slash commands and media-only sends
* stay in the flat lane. */
private fun wantsAutoThread(text: String, threadId: String?): Boolean =
_threadsEnabled.value && threadId == null && text.isNotEmpty() && !text.startsWith("/")
/** M7: resend a failed user message (tap on the failed bubble). */
fun retrySend(messageId: String) {
val lane = chat.currentLane.value
@@ -405,7 +618,25 @@ class IrisController(
val item = chat.lanes.value[lane]?.firstOrNull { it.id == messageId } as? MessageItem ?: return
if (item.status != MsgStatus.Failed) return
chat.rearmForRetry(lane, messageId)
client.sendMessage(chatId, item.text, threadId, item.media.map { it.mediaId })
client.sendMessage(
chatId,
item.text,
threadId,
item.media.map { it.mediaId },
autoThread = wantsAutoThread(item.text, threadId),
)
}
/** Delete the given message(s) from the current lane (long-press select →
* delete). The server removes them from the outbox and broadcasts
* `message.deleted`; the local cache drops them on that frame (or
* immediately, below, for snappy UX). */
fun deleteMessages(messageIds: List<String>) {
if (messageIds.isEmpty()) return
val lane = chat.currentLane.value
val (chatId, threadId) = chat.parseLane(lane)
chat.removeMessages(messageIds.toSet())
client.sendFrame(messageDeleteFrame(0, chatId, messageIds, threadId))
}
// ── M4: attachments (pick -> upload -> send) ──────────────────────────
@@ -482,9 +713,42 @@ class IrisController(
}
}
/** Convert a history message (server) into a chat [MessageItem]. */
private fun HistoryMessage.toMessageItem(): MessageItem =
MessageItem(
id = messageId,
role = role,
text = text,
ts = ts ?: 0,
reasoning = reasoning,
model = model,
tokens = tokens,
media = media.map {
MediaItem(mediaId = it.mediaId, kind = it.kind, mime = it.mime, size = it.size, filename = it.filename)
},
)
// Gateway restart routine (hermes, same icon + wording on all platforms). The
// app generates both notices locally, on the down/up state transition, so the
// order is guaranteed (restarting before online) and there is no dependency on
// the server frame (which may be dropped on shutdown or replayed out of order
// by the sync catch-up). The core does not send a startup/online notice to this
// platform, and its "restarting" commentary frame is dropped in ChatStore.
private const val GATEWAY_RESTARTING_MSG =
"⚠️ Gateway restarting — Your current task will be interrupted. Send any message after restart and I'll try to resume where you left off."
private const val GATEWAY_ONLINE_MSG =
"♻️ Gateway online — Hermes is back and ready."
/** How much tool detail to show (Settings → "Tool detail"). */
enum class ToolDetail {
EVERYTHING, // name + full args (collapsible) + output preview
TRUNCATED, // one-liner "emoji name: preview", collapsible (default)
NOTHING, // suppress tool cards entirely
;
companion object {
/** Parse a persisted value; unknown/blank falls back to the default. */
fun fromName(name: String): ToolDetail =
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: TRUNCATED
}
}
@@ -0,0 +1,10 @@
package iris.ui
import androidx.compose.ui.Modifier
/**
* Opens [onRightClick] on a right (secondary) mouse-button press. Desktop-only
* behavior; on touch platforms this is a no-op (the context menu is opened via
* long-press instead).
*/
expect fun Modifier.rightClick(onRightClick: () -> Unit): Modifier
@@ -0,0 +1,212 @@
package iris.ui
import androidx.compose.foundation.background
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.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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.CodeOff
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
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.State
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.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.snipme.highlights.Highlights
import iris.platform.PlatformWebView
import iris.platform.handleSystemBack
import iris.ui.theme.IrisColors
/**
* M9: HTML artifact support. When the agent emits a complete HTML document
* (HTML + CSS + JS) in a fenced code block, the bubble renders an artifact
* card instead of plain highlighted code. "Preview" opens the document in a
* full-screen in-app WebView with JavaScript enabled, so interactive scripts
* run inside the app.
*/
/**
* True when a fenced code block should be rendered as a runnable HTML
* artifact: an explicit ```html / ```htm language tag, or an untagged block
* that starts with a complete HTML document.
*/
fun isHtmlArtifact(language: String?, code: String): Boolean {
val lang = language?.trim()?.lowercase()
if (lang == "html" || lang == "htm") return true
if (lang != null) return false
val head = code.trimStart().take(64).lowercase()
return head.startsWith("<!doctype html") || head.startsWith("<html")
}
/** The document's <title> (card / overlay header), or null when absent. */
fun htmlTitle(code: String): String? =
TITLE_REGEX.find(code)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
private val TITLE_REGEX =
Regex("<title[^>]*>(.*?)</title>", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
/** The artifact currently open in the full-screen preview (null = closed). */
data class ArtifactPreviewData(val html: String, val title: String?)
/**
* Process-wide preview state: the artifact card (deep in the message list)
* writes here, the overlay at the app root reads it.
*/
object ArtifactPreview {
private val _current = mutableStateOf<ArtifactPreviewData?>(null)
val current: State<ArtifactPreviewData?> get() = _current
fun show(html: String, title: String?) {
_current.value = ArtifactPreviewData(html, title)
}
fun hide() {
_current.value = null
}
}
/**
* Artifact card shown in place of a highlighted HTML code block: title row
* with a Preview button (opens the full-screen overlay) and a code toggle
* (reveals the highlighted source with its copy button).
*/
@Composable
fun HtmlArtifactCard(
code: String,
style: TextStyle,
highlights: Highlights.Builder,
) {
val title = remember(code) { htmlTitle(code) }
var showCode by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = IrisColors.panel,
) {
Row(
modifier = Modifier.padding(start = 12.dp, top = 6.dp, bottom = 6.dp, end = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Code,
contentDescription = null,
tint = IrisColors.statusGreen,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title ?: "HTML artifact",
color = IrisColors.textBright,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "Interactive HTML preview",
color = IrisColors.textMuted,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Button(
onClick = { ArtifactPreview.show(code, title) },
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 12.dp,
vertical = 4.dp,
),
) {
Text("Preview", style = MaterialTheme.typography.bodyMedium)
}
IconButton(onClick = { showCode = !showCode }) {
Icon(
imageVector = if (showCode) Icons.Filled.CodeOff else Icons.Filled.Code,
contentDescription = if (showCode) "Hide code" else "Show code",
tint = IrisColors.textSecondary,
)
}
}
}
if (showCode) {
Spacer(modifier = Modifier.height(6.dp))
CodeBlockWithCopy(code = code, language = "html", style = style, highlights = highlights)
}
}
}
/**
* Full-screen artifact preview overlay (rendered at the app root). Covers the
* whole window; the header close button and the system back button (Android)
* dismiss it.
*/
@Composable
fun ArtifactPreviewOverlay() {
val preview by ArtifactPreview.current
if (preview == null) return
val data = preview!!
Box(
modifier = Modifier
.fillMaxSize()
.background(IrisColors.background)
.handleSystemBack { ArtifactPreview.hide() },
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, end = 2.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Code,
contentDescription = null,
tint = IrisColors.statusGreen,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = data.title ?: "HTML preview",
modifier = Modifier.weight(1f),
color = IrisColors.textBright,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
IconButton(onClick = { ArtifactPreview.hide() }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close preview",
tint = IrisColors.textSecondary,
)
}
}
HorizontalDivider(color = IrisColors.divider)
PlatformWebView(html = data.html, modifier = Modifier.fillMaxSize().weight(1f))
}
}
}
@@ -0,0 +1,229 @@
package iris.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
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.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.model.rememberMarkdownState
import com.mikepenz.markdown.utils.getUnescapedTextInNode
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes
import iris.ui.theme.IrisColors
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.intellij.markdown.MarkdownElementTypes
/**
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM
* tables, task-list checkboxes, and fenced code blocks with language-aware
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
* rendered text blends in.
*/
@Composable
fun MarkdownText(
text: String,
color: Color,
fontSize: TextUnit,
modifier: Modifier = Modifier,
// M9: while the message is still streaming, HTML code blocks render as
// plain highlighted code — the artifact card (and its runnable preview)
// only appears once the message is complete.
isStreaming: Boolean = false,
) {
val state = rememberMarkdownState(text)
// The app is always dark-themed, so force the dark highlight palette
// (isSystemInDarkTheme() is unreliable on desktop).
val highlights = remember {
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
}
val base = TextStyle(color = color, fontSize = fontSize)
val inlineCodeBackground = Color.White.copy(alpha = 0.08f)
// Inline-code span style (mirrors the library's codeSpanStyle): the
// monospace inlineCode typography plus the chip background.
val codeSpanStyle =
base.copy(fontFamily = FontFamily.Monospace, background = inlineCodeBackground).toSpanStyle()
// Brief green flash shown on the chip right after a copy, so the tap is
// visible feedback (the clipboard write itself is silent).
val copiedCodeSpanStyle =
base.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
.toSpanStyle()
val clipboard = LocalClipboardManager.current
val scope = rememberCoroutineScope()
// The text of the inline code most recently copied (null when idle). Read
// inside the annotator so a change re-renders the chip with the flash.
val copiedCode = remember { mutableStateOf<String?>(null) }
// The default CODE_SPAN handler pads inline code with a leading and a
// trailing space, which doubled the whitespace around `code` mid-line.
// Re-render it without the padding, and wrap it in a link so tapping the
// inline code copies it to the clipboard (Telegram-style). The
// LinkAnnotation carries the click listener; the URL is never opened.
val annotator = markdownAnnotator(
annotate = { content, child ->
if (child.type == MarkdownElementTypes.CODE_SPAN) {
val children = child.children
// Drop the surrounding backtick tokens (present as first/last child).
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
val code = inner.joinToString("") { it.getUnescapedTextInNode(content) }
val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle
pushStyle(spanStyle)
withLink(
LinkAnnotation.Url(
url = "iris:copy-code",
// Keep every interaction state identical to the chip so
// hover/press never restyles the inline code.
styles = TextLinkStyles(
style = spanStyle,
focusedStyle = spanStyle,
hoveredStyle = spanStyle,
pressedStyle = spanStyle,
),
linkInteractionListener = LinkInteractionListener {
clipboard.setText(AnnotatedString(code))
copiedCode.value = code
scope.launch {
delay(600)
copiedCode.value = null
}
},
),
) {
append(code)
}
pop()
true
} else {
false
}
},
)
Markdown(
markdownState = state,
modifier = modifier,
annotator = annotator,
colors = markdownColor(
text = color,
codeBackground = Color.Black.copy(alpha = 0.8f),
inlineCodeBackground = inlineCodeBackground,
dividerColor = IrisColors.divider,
tableBackground = Color.White.copy(alpha = 0.03f),
),
typography = markdownTypography(
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
h4 = base.copy(fontWeight = FontWeight.Bold),
h5 = base.copy(fontWeight = FontWeight.Bold),
h6 = base.copy(fontWeight = FontWeight.Bold),
text = base,
code = base.copy(fontSize = fontSize * 0.9f, fontFamily = FontFamily.Monospace),
inlineCode = base.copy(fontFamily = FontFamily.Monospace),
quote = base.copy(fontStyle = FontStyle.Italic),
paragraph = base,
ordered = base,
bullet = base,
list = base,
link = base.copy(textDecoration = TextDecoration.Underline),
table = base.copy(fontSize = fontSize * 0.95f),
),
components = markdownComponents(
codeFence = { model ->
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
if (!isStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
}
}
},
codeBlock = { model ->
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
if (!isStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
}
}
},
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
// Material 3 checkbox instead.
checkbox = {
com.mikepenz.markdown.m3.elements.MarkdownCheckBox(it.content, it.node, it.typography.text)
},
),
loading = { m ->
// While (re)parsing — e.g. on each streaming update — show the raw
// text so the bubble never goes blank between updates.
Text(text, modifier = m, color = color, fontSize = fontSize)
},
)
}
/**
* Renders a highlighted code block with a copy button in the top-right corner
* (Telegram-style). Tapping the button copies the whole block to the clipboard.
* Also used by [HtmlArtifactCard] to reveal the source of an HTML artifact.
*/
@Composable
internal fun CodeBlockWithCopy(
code: String,
language: String?,
style: TextStyle,
highlights: Highlights.Builder,
) {
val clipboard = LocalClipboardManager.current
Box {
MarkdownHighlightedCode(code = code, language = language, highlights = highlights, style = style)
if (code.isNotBlank()) {
IconButton(
onClick = { clipboard.setText(AnnotatedString(code)) },
modifier = Modifier
.align(Alignment.TopEnd)
// MarkdownHighlightedCode insets its background by 8dp top;
// match that so the button sits inside the block, not above it.
.padding(top = 8.dp, end = 4.dp)
.size(28.dp),
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy code",
tint = Color.White.copy(alpha = 0.6f),
)
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,6 @@
package iris.ui.screens
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -14,7 +12,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -135,20 +132,3 @@ fun ConnectScreen(
}
}
/** M7: shown while the initial connection is in flight (state == Connecting). */
@Composable
fun ConnectingScreen(host: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator()
Text(
"Connecting to $host…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,480 @@
package iris.ui.screens
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.platform.ImageFilePicker
import iris.platform.loadScaledImage
import iris.state.IrisController
import iris.state.ToolDetail
import iris.ui.theme.BackgroundMode
import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme
import kotlin.math.roundToInt
/**
* Settings screen (post-M7): app-level preferences, opened from the channel
* drawer / rail. More settings land here over time.
*/
/** Which color the picker dialog is editing. */
private enum class PickerTarget { UserBubble, AgentBubble, Background }
@Composable
fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val toolDetail by controller.toolDetail.collectAsState()
val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 12.dp),
) {
HeaderIconButton(onClick = onBack) {
BackArrow()
}
Spacer(modifier = Modifier.width(8.dp))
Text("Settings", style = MaterialTheme.typography.titleMedium)
}
Text(
"Chat",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(bottom = 4.dp),
)
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("🧵 Threads", fontSize = 14.sp)
Text(
"Show topics; new messages in General start their own topic (AI-named)",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
Switch(
checked = threadsEnabled,
onCheckedChange = { controller.toggleThreads() },
)
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("⚡ Streaming", fontSize = 14.sp)
Text(
"Show replies as they are generated",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
Switch(
checked = streamingEnabled,
onCheckedChange = { controller.toggleStreaming() },
)
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("🧠 Reasoning", fontSize = 14.sp)
Text(
"Auto-collapse long reasoning blocks",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
Switch(
checked = reasoningAutoCollapse,
onCheckedChange = { controller.toggleReasoningAutoCollapse() },
)
}
}
Text(
"Appearance",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("💬 Your bubble", fontSize = 14.sp)
Text(
"Color of your message bubbles",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
ColorSwatch(theme.userBubble) { pickerTarget = PickerTarget.UserBubble }
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("🤖 Agent bubble", fontSize = 14.sp)
Text(
"Color of Hermes' message bubbles",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
ColorSwatch(theme.agentBubble) { pickerTarget = PickerTarget.AgentBubble }
}
}
SettingsCard {
Text("🖼 Background", fontSize = 14.sp)
Text(
"Solid color or image behind the chat",
fontSize = 12.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TopicChip(
label = "Color",
selected = theme.backgroundMode == BackgroundMode.Color,
onClick = { controller.setBackgroundMode(BackgroundMode.Color) },
)
TopicChip(
label = "Image",
selected = theme.backgroundMode == BackgroundMode.Image,
onClick = { controller.setBackgroundMode(BackgroundMode.Image) },
)
}
if (theme.backgroundMode == BackgroundMode.Color) {
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("Background color", fontSize = 12.sp, color = IrisColors.textDim)
}
ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background }
}
} else {
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
BackgroundPreview(theme.backgroundImagePath)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
if (theme.backgroundImagePath.isNullOrBlank()) "No image selected"
else fileNameOf(theme.backgroundImagePath!!),
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
ImageFilePicker { picked ->
picked?.let { controller.setBackgroundImagePath(it.path) }
}
}
if (!theme.backgroundImagePath.isNullOrBlank()) {
TextButton(onClick = { controller.removeBackgroundImage() }) {
Text("Remove image", fontSize = 12.sp)
}
}
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("Reset appearance", fontSize = 14.sp)
Text(
"Restore the default colors and background",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
TextButton(onClick = { controller.resetTheme() }) {
Text("Reset", fontSize = 12.sp)
}
}
}
Text(
"Tools",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
Text("🔧 Tool detail", fontSize = 14.sp)
Text(
"How much tool activity to show",
fontSize = 12.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ToolDetail.entries.forEach { d ->
TopicChip(
label = d.label.replaceFirstChar { it.uppercaseChar() },
selected = toolDetail == d,
onClick = { controller.setToolDetail(d) },
)
}
}
}
}
when (pickerTarget) {
PickerTarget.UserBubble -> ColorPickerDialog(
title = "Your bubble",
initialArgb = theme.userBubbleColor,
onApply = { controller.setUserBubbleColor(it) },
onConfirm = {
controller.setUserBubbleColor(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
PickerTarget.AgentBubble -> ColorPickerDialog(
title = "Agent bubble",
initialArgb = theme.agentBubbleColor,
onApply = { controller.setAgentBubbleColor(it) },
onConfirm = {
controller.setAgentBubbleColor(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
PickerTarget.Background -> ColorPickerDialog(
title = "Background",
initialArgb = theme.backgroundColor,
onApply = { controller.setBackgroundColor(it) },
onConfirm = {
controller.setBackgroundColor(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
null -> Unit
}
}
}
/** Material-style back arrow (vector, matches the app's icon weight). */
@Composable
private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) {
Canvas(modifier = Modifier.size(size)) {
val s = this.size.width / 24f
val path = Path().apply {
moveTo(20f * s, 11f * s)
lineTo(7.83f * s, 11f * s)
lineTo(13.42f * s, 5.41f * s)
lineTo(12f * s, 4f * s)
lineTo(4f * s, 12f * s)
lineTo(12f * s, 20f * s)
lineTo(13.41f * s, 18.59f * s)
lineTo(7.83f * s, 13f * s)
lineTo(20f * s, 13f * s)
close()
}
drawPath(path, color)
}
}
/** Settings row container (panel card). */
@Composable
private fun SettingsCard(content: @Composable () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(IrisColors.panel)
.padding(12.dp),
) {
content()
}
}
/** Clickable rounded chip showing a color; opens the picker on tap. */
@Composable
private fun ColorSwatch(color: Color, onClick: () -> Unit) {
Box(
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(8.dp))
.background(color)
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp))
.clickable(onClick = onClick),
)
}
/** Small thumbnail of the current background image (empty panel when none). */
@Composable
private fun BackgroundPreview(path: String?) {
var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(path) {
bmp = if (path.isNullOrBlank()) null else loadScaledImage(path, 128)
}
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(8.dp))
.background(IrisColors.panel)
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
val b = bmp
if (b != null) {
Image(
bitmap = b,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
}
/**
* HSV color picker dialog (hue / saturation / brightness sliders).
* [onApply] applies the color and keeps the dialog open for further tweaks;
* [onConfirm] applies and closes; [onDismiss] closes without applying.
*/
@Composable
private fun ColorPickerDialog(
title: String,
initialArgb: Int,
onApply: (Int) -> Unit,
onConfirm: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val initial = remember { argbToHsv(initialArgb) }
var hue by remember { mutableFloatStateOf(initial[0]) }
var sat by remember { mutableFloatStateOf(initial[1]) }
var bri by remember { mutableFloatStateOf(initial[2]) }
val color = Color.hsv(hue, sat, bri)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(8.dp))
.background(color),
)
Spacer(modifier = Modifier.height(12.dp))
Slider(value = hue, onValueChange = { hue = it }, valueRange = 0f..360f)
Slider(value = sat, onValueChange = { sat = it }, valueRange = 0f..1f)
Slider(value = bri, onValueChange = { bri = it }, valueRange = 0f..1f)
Text(
"#%06X".format(color.toArgbInt() and 0xFFFFFF),
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
},
confirmButton = {
TextButton(onClick = { onApply(color.toArgbInt()) }) { Text("Apply") }
TextButton(onClick = { onConfirm(color.toArgbInt()) }) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
/** Compose Color → ARGB int (no Color.toArgb in common code). */
private fun Color.toArgbInt(): Int {
fun c(v: Float) = (v * 255f).roundToInt().coerceIn(0, 255)
return (c(alpha) shl 24) or (c(red) shl 16) or (c(green) shl 8) or c(blue)
}
/** ARGB int → [hue (deg), saturation, brightness] (alpha ignored). */
private fun argbToHsv(argb: Int): FloatArray {
val r = ((argb shr 16) and 0xFF) / 255f
val g = ((argb shr 8) and 0xFF) / 255f
val b = (argb and 0xFF) / 255f
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val delta = max - min
var hue = 0f
if (delta != 0f) {
hue = when (max) {
r -> ((g - b) / delta) % 6f
g -> (b - r) / delta + 2f
else -> (r - g) / delta + 4f
}
hue *= 60f
if (hue < 0f) hue += 360f
}
val sat = if (max == 0f) 0f else delta / max
return floatArrayOf(hue, sat, max)
}
/** File name from a path (no java.io.File in common code). */
private fun fileNameOf(path: String): String =
path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
@@ -3,7 +3,15 @@ package iris.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
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.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import iris.platform.loadScaledImage
/**
* Centralized palette (M7). Dark is the only theme; every screen reads colors
@@ -34,7 +42,6 @@ object IrisColors {
val chip = Color(0xFF2A2E3B)
val chipSelected = Color(0xFF2A3550)
val rowSelected = Color(0xFF232838)
val reasoningPanel = Color(0xFF1E212B)
val divider = Color(0xFF2A2E3B)
// Status
@@ -70,3 +77,53 @@ fun avatarColor(name: String): Color {
val hue = ((name.hashCode() and 0x7FFFFFFF) % 360).toFloat()
return Color.hsv(hue, 0.5f, 0.75f)
}
/**
* User-customizable appearance (Settings → Appearance). Colors are stored as
* ARGB ints so they can be persisted without Compose types.
*/
data class UserTheme(
val userBubbleColor: Int = DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE,
val backgroundMode: BackgroundMode = BackgroundMode.Color,
val backgroundColor: Int = DEFAULT_BACKGROUND,
val backgroundImagePath: String? = null,
) {
val userBubble: Color get() = Color(userBubbleColor)
val agentBubble: Color get() = Color(agentBubbleColor)
val background: Color get() = Color(backgroundColor)
companion object {
val DEFAULT_USER_BUBBLE: Int = 0xFF4F7CFF.toInt()
val DEFAULT_AGENT_BUBBLE: Int = 0xFF2A2E3B.toInt()
val DEFAULT_BACKGROUND: Int = 0xFF1B1E28.toInt()
}
}
/** Background style: solid color or image. */
enum class BackgroundMode { Color, Image }
/** Current user theme, provided at the app root (IrisApp). */
val LocalUserTheme = staticCompositionLocalOf { UserTheme() }
/** Black or white, whichever reads better on [this]. */
fun Color.contrastText(): Color {
val luminance = 0.299f * red + 0.587f * green + 0.114f * blue
return if (luminance > 0.6f) Color(0xFF1B1E28) else Color.White
}
/**
* Decodes the theme's background image (image mode only), scaled to at most
* 2048 px on the longest edge. Null in color mode or on decode failure — the
* caller then falls back to the solid background color.
*/
@Composable
fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
val path = theme.backgroundImagePath
val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank()
var image by remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(wantImage, path) {
image = if (wantImage) loadScaledImage(path!!, 2048) else null
}
return image
}
@@ -0,0 +1,39 @@
package iris.util
/**
* Drops the leading newlines / blank lines a model reply often starts with, so
* the rendered text hugs the top of the bubble instead of leaving a blank gap.
* Leading indentation on the first real line is preserved, so an indented code
* block at the very start of a message still parses as code.
*/
fun String.prepareForMarkdown(): String {
val lines = split("\n")
var i = 0
while (i < lines.size && lines[i].isBlank()) i++
return lines.drop(i).joinToString("\n")
}
/**
* Converts the single newlines a user typed into markdown hard breaks (two
* trailing spaces) so they render as line breaks instead of being collapsed
* into spaces by the parser (GFM treats a lone newline inside a paragraph as a
* space). Lines inside fenced code blocks (``` / ~~~) are left untouched so the
* code content is not altered; blank lines are kept as-is since they already
* produce paragraph breaks.
*/
fun String.preserveNewlinesAsHardBreaks(): String {
val lines = split("\n")
var inCodeBlock = false
val out = lines.map { line ->
val trimmed = line.trimStart()
when {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
inCodeBlock = !inCodeBlock
line
}
inCodeBlock || line.isBlank() -> line
else -> line + " "
}
}
return out.joinToString("\n")
}
@@ -9,6 +9,9 @@ expect fun formatTime(epochMillis: Long): String
/** Local calendar-day key used to detect date changes between messages (M7). */
expect fun localDayKey(epochMillis: Long): String
/** Current wall-clock time in epoch milliseconds (for locally generated items). */
expect fun nowMillis(): Long
/** Host part of a pairing URL ("ws://host:port/ws" -> "host:port"). */
fun hostFromUrl(url: String): String {
val noScheme = url.trim().substringAfter("://")
@@ -0,0 +1,65 @@
package iris.ui
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class HtmlArtifactTest {
@Test
fun htmlLanguageTagIsArtifact() {
assertTrue(isHtmlArtifact("html", "<p>hi</p>"))
assertTrue(isHtmlArtifact("htm", "<p>hi</p>"))
assertTrue(isHtmlArtifact("HTML", "<p>hi</p>"))
assertTrue(isHtmlArtifact(" html ", "<p>hi</p>"))
}
@Test
fun otherLanguageTagsAreNotArtifacts() {
assertFalse(isHtmlArtifact("css", "<style>x</style>"))
assertFalse(isHtmlArtifact("js", "console.log(1)"))
assertFalse(isHtmlArtifact("kotlin", "fun main() {}"))
// Even a full document inside a non-html fence is not an artifact.
assertFalse(isHtmlArtifact("text", "<!DOCTYPE html><html></html>"))
}
@Test
fun untaggedCompleteDocumentIsArtifact() {
assertTrue(isHtmlArtifact(null, "<!DOCTYPE html>\n<html><body>hi</body></html>"))
assertTrue(isHtmlArtifact(null, " <html><body>hi</body></html>"))
assertTrue(isHtmlArtifact(null, "<HTML><BODY>hi</BODY></HTML>"))
}
@Test
fun untaggedFragmentIsNotArtifact() {
assertFalse(isHtmlArtifact(null, "<div>just a fragment</div>"))
assertFalse(isHtmlArtifact(null, "Some prose with <b>bold</b> inside."))
assertFalse(isHtmlArtifact(null, ""))
}
@Test
fun extractsTitle() {
assertEquals(
"My Page",
htmlTitle("<html><head><title>My Page</title></head><body></body></html>"),
)
assertEquals(
"Spaced",
htmlTitle("<title> Spaced </title>"),
)
// Case-insensitive, attributes allowed.
assertEquals(
"X",
htmlTitle("<TITLE lang=\"en\">X</TITLE>"),
)
}
@Test
fun missingOrEmptyTitleIsNull() {
assertNull(htmlTitle("<html><body>no title</body></html>"))
assertNull(htmlTitle("<title></title>"))
assertNull(htmlTitle("<title> </title>"))
}
}
@@ -0,0 +1,42 @@
package iris.util
import kotlin.test.Test
import kotlin.test.assertEquals
class MarkdownTest {
@Test
fun stripsLeadingNewlines() {
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
assertEquals("Hello", "\nHello".prepareForMarkdown())
assertEquals("Hello", "\r\n\r\nHello".prepareForMarkdown())
}
@Test
fun stripsLeadingBlankLines() {
assertEquals("Hello", "\n \n\t\nHello".prepareForMarkdown())
assertEquals("a\nb", "\n\na\nb".prepareForMarkdown())
}
@Test
fun preservesLeadingIndentationOnFirstLine() {
// An indented code block at the very start must still parse as code.
assertEquals(" code", "\n\n code".prepareForMarkdown())
}
@Test
fun leavesInteriorWhitespaceUntouched() {
val md = "Line one\n\nLine two\n\n- item"
assertEquals(md, md.prepareForMarkdown())
}
@Test
fun noLeadingNewlinesIsUnchanged() {
assertEquals("Hello world", "Hello world".prepareForMarkdown())
}
@Test
fun allBlankBecomesEmpty() {
assertEquals("", "\n\n \n".prepareForMarkdown())
}
}
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.data.MediaItem
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
import java.net.StandardProtocolFamily
@@ -40,6 +41,7 @@ import java.nio.channels.SocketChannel
import java.util.concurrent.TimeUnit
import javax.imageio.ImageIO
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@@ -93,6 +95,33 @@ private fun pickFile(): PickedFile? {
)
}
@Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
Button(onClick = {
val chooser = JFileChooser().apply {
fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif")
}
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) {
onPicked(null)
return@Button
}
val f = chooser.selectedFile ?: return@Button
try {
// Copy into the persistent ~/.iris dir so the theme survives
// deletion of the original file.
val dir = File(File(System.getProperty("user.home"), ".iris"), "theme").apply { mkdirs() }
val ext = f.name.substringAfterLast('.', "img").lowercase()
val target = File(dir, "background.$ext")
f.copyTo(target, overwrite = true)
onPicked(PickedFile(target.absolutePath, f.name, guessMime(f.name), target.length()))
} catch (e: Exception) {
onPicked(null)
}
}) {
Text("Choose image…")
}
}
private fun guessMime(name: String): String {
val ext = name.substringAfterLast('.', "").lowercase()
return when (ext) {
@@ -131,6 +160,54 @@ actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Disp
}
}
actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) {
try {
val img = ImageIO.read(File(path)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
if (scale >= 1f) {
img.toComposeImageBitmap()
} else {
val sw = (img.width * scale).toInt().coerceAtLeast(1)
val sh = (img.height * scale).toInt().coerceAtLeast(1)
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
val g = scaled.createGraphics()
g.drawImage(img, 0, 0, sw, sh, null)
g.dispose()
scaled.toComposeImageBitmap()
}
} catch (_: Exception) {
null
}
}
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
try {
val img = ImageIO.read(File(path)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
val sw = (img.width * scale).toInt().coerceAtLeast(1)
val sh = (img.height * scale).toInt().coerceAtLeast(1)
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
val g = scaled.createGraphics()
g.drawImage(img, 0, 0, sw, sh, null)
g.dispose()
val baos = ByteArrayOutputStream()
ImageIO.write(scaled, "png", baos)
java.util.Base64.getEncoder().encodeToString(baos.toByteArray())
} catch (_: Exception) {
null
}
}
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
try {
val bytes = java.util.Base64.getDecoder().decode(base64)
val img = ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: return@withContext null
img.toComposeImageBitmap()
} catch (_: Exception) {
null
}
}
/**
* M6: inline media playback backed by mpv (docs/11 §11.2).
*
@@ -2,6 +2,7 @@ package iris.platform
import iris.data.SecureStore
import iris.protocol.IrisJson
import iris.ui.theme.UserTheme
import java.io.File
import java.security.SecureRandom
import java.util.UUID
@@ -34,6 +35,15 @@ class DesktopSecureStore : SecureStore {
val fcmToken: String = "",
val ntfyTopic: String = "",
val ntfyServer: String = "",
val threadsEnabled: Boolean = false,
val toolDetail: String = "truncated",
val streamingEnabled: Boolean = true,
val reasoningAutoCollapse: Boolean = true,
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
val backgroundMode: String = "color",
val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND,
val backgroundImagePath: String = "",
)
init {
@@ -133,6 +143,69 @@ class DesktopSecureStore : SecureStore {
save(d.copy(ntfyServer = value))
}
override var threadsEnabled: Boolean
get() = load().threadsEnabled
set(value) {
val d = load()
save(d.copy(threadsEnabled = value))
}
override var toolDetail: String
get() = load().toolDetail.ifBlank { "truncated" }
set(value) {
val d = load()
save(d.copy(toolDetail = value))
}
override var streamingEnabled: Boolean
get() = load().streamingEnabled
set(value) {
val d = load()
save(d.copy(streamingEnabled = value))
}
override var reasoningAutoCollapse: Boolean
get() = load().reasoningAutoCollapse
set(value) {
val d = load()
save(d.copy(reasoningAutoCollapse = value))
}
override var userBubbleColor: Int
get() = load().userBubbleColor
set(value) {
val d = load()
save(d.copy(userBubbleColor = value))
}
override var agentBubbleColor: Int
get() = load().agentBubbleColor
set(value) {
val d = load()
save(d.copy(agentBubbleColor = value))
}
override var backgroundMode: String
get() = load().backgroundMode.ifBlank { "color" }
set(value) {
val d = load()
save(d.copy(backgroundMode = value))
}
override var backgroundColor: Int
get() = load().backgroundColor
set(value) {
val d = load()
save(d.copy(backgroundColor = value))
}
override var backgroundImagePath: String
get() = load().backgroundImagePath
set(value) {
val d = load()
save(d.copy(backgroundImagePath = value))
}
override fun savePairing(url: String, token: String) {
val d = load()
save(d.copy(serverUrl = url.trim()))
@@ -0,0 +1,94 @@
package iris.platform
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.multiplatform.webview.web.WebView
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
import dev.datlag.kcef.KCEF
import iris.ui.theme.IrisColors
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
/**
* M9: HTML artifact preview on desktop — JCEF (Chromium) via KCEF.
*
* KCEF is initialized once per process, lazily on the first artifact
* preview. The JCEF/Chromium binary is downloaded from the pinned
* JetBrainsRuntime release on first use and cached in ~/.iris/kcef-bundle,
* so later starts (and the packaged app) reuse it.
*/
private val kcefReady = MutableStateFlow(false)
private val kcefProgress = MutableStateFlow(0f)
private val kcefError = MutableStateFlow<String?>(null)
private suspend fun ensureKcefInitialized() {
if (kcefReady.value) return
withContext(Dispatchers.IO) {
KCEF.init(
builder = {
installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle"))
// Pinned to the JBR 17 release the KCEF 2024.04.20.x line was
// built against; the default "latest" JBR may be JDK 21-based
// and is not guaranteed compatible.
download {
github { release("jbr-release-17.0.12b1207.37") }
}
settings {
cachePath = File(System.getProperty("user.home"), ".iris/kcef-cache").absolutePath
}
progress {
onDownloading { kcefProgress.value = it }
onInitialized { kcefReady.value = true }
}
},
onError = { kcefError.value = it?.message ?: "Preview engine failed to start" },
onRestartRequired = { kcefError.value = "Restart the app to finish setting up previews" },
)
}
}
@Composable
actual fun PlatformWebView(html: String, modifier: Modifier) {
val ready by kcefReady.collectAsState()
val progress by kcefProgress.collectAsState()
val error by kcefError.collectAsState()
LaunchedEffect(Unit) { ensureKcefInitialized() }
Box(modifier = modifier.fillMaxSize()) {
when {
error != null -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(error!!, color = IrisColors.errorText)
}
}
!ready -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(progress = { progress })
Text(
text = "Preparing preview… ${progress.toInt()}%",
color = IrisColors.textSecondary,
)
}
}
}
else -> {
val state = rememberWebViewStateWithHTMLData(data = html)
WebView(state, modifier = Modifier.fillMaxSize())
}
}
}
}
@Composable
actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier = this
@@ -0,0 +1,20 @@
package iris.ui
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
@OptIn(ExperimentalComposeUiApi::class)
actual fun Modifier.rightClick(onRightClick: () -> Unit): Modifier =
pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Press && event.button == PointerButton.Secondary) {
onRightClick()
}
}
}
}
@@ -25,3 +25,5 @@ actual fun formatTime(epochMillis: Long): String {
actual fun localDayKey(epochMillis: Long): String =
Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()).toLocalDate().toString()
actual fun nowMillis(): Long = System.currentTimeMillis()
+37 -5
View File
@@ -66,6 +66,16 @@ Streaming a bubble. `update` carries the **full** current text (app replaces).
"reasoning":"…","model":"…","tokens":11}}
```
### `message.deleted`
The given message(s) were deleted from a chat/thread. Response to a
`message.delete` request (`id` set) **and** broadcast to every device so all of
them drop the message(s) from their cache. Also outboxed, so a device that was
offline learns of the deletion on its next `sync`.
```json
{"type":"message.deleted","id":30,"chat_id":"android:default","thread_id":null,
"payload":{"message_ids":["m_9001","m_9002"]}}
```
### `commentary`
Intermediate assistant beat (between tool iterations).
```json
@@ -116,6 +126,10 @@ subscribe; the server pushes to every open WS).
{"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports",
"kind":"channel","parent_chat_id":null}}
```
`channel.created` may carry `"auto":true` for a thread the gateway minted
itself for an incoming message (auto-threading): the name is an instant
derived title, and a follow-up `channel.renamed` upgrades it to the model's
title.
### `history`
Response to a `history` request. Returns a page of messages for a chat/thread.
@@ -225,9 +239,16 @@ First frame; auth + caps.
Send text (or a `/slash-command`).
```json
{"type":"message.send","id":10,"chat_id":"android:default","thread_id":null,
"payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"]}}
"payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"],
"auto_thread":false}}
```
`media_refs` reference completed `media.upload`s to attach.
`auto_thread` (optional, default false) asks the gateway to mint a fresh
thread for the message (auto-threading, `06-channels-cron-search.md` §6.3):
honored only in a channel's flat lane (`thread_id` null) with non-empty text
that is not a slash command. The gateway then broadcasts
`channel.created {auto:true}` and the echo / agent turn carry the new
`thread_id`.
### `media.upload.start` / (binary) / `media.upload.end`
See `07-media.md`.
@@ -289,6 +310,16 @@ Load a page of messages for a chat/thread (initial open, scroll-up pagination).
`before_message_id` — return messages older than this (omit for newest page).
`limit` — max messages (default 50, max 200).
### `message.delete`
Delete the given message(s) from a chat/thread. The server removes them from the
outbox (so `history`/`sync` no longer return them) and broadcasts
`message.deleted` to every device. Idempotent: a message already gone (pruned by
retention) still yields a `message.deleted` broadcast so live caches drop it.
```json
{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null,
"payload":{"message_ids":["m_9001","m_9002"]}}
```
### `commands.catalog`
Fetch the full slash-command catalog (for the `Menü` bottom sheet).
```json
@@ -335,10 +366,11 @@ Keepalive. `{"type":"ping","payload":{"ts":1724000000000}}` → `pong`.
- Frames are ordered per connection (TCP/WS). Streaming `message.update` frames
for a `message_id` are monotonic; the app may coalesce to the latest.
- Terminal frames (`message`, `message.stop`, `tool.end`, `notification`,
`picker.*`, `channel.*`, `agent.busy`, `agent.idle`, `history`,
`commands.catalog`, `commands.complete`) are **never dropped** under
backpressure; only intermediate `message.update`/`tool.progress` are coalesced.
- Terminal frames (`message`, `message.stop`, `message.deleted`, `tool.end`,
`notification`, `picker.*`, `channel.*`, `agent.busy`, `agent.idle`,
`history`, `commands.catalog`, `commands.complete`) are **never dropped**
under backpressure; only intermediate `message.update`/`tool.progress` are
coalesced.
- Anything not delivered live goes to the **outbox** and is replayed by `sync`.
- **Broadcast:** channel directory events (`channel.*`) and read-receipts are
pushed to **all** connected devices for that gateway (no subscribe step).
+9 -2
View File
@@ -27,8 +27,15 @@ replace the bubble text (cheap: it's a full snapshot). On `message.stop`,
finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
while the user is at the bottom.
**Streaming on/off.** Controlled by hermes `display.platforms.android.streaming`
(default follows global). When off, the app just gets one final `message` frame.
**Streaming on/off.** Two levels:
- **Gateway side:** hermes `display.platforms.android.streaming` (default
follows global). When off, the app just gets one final `message` frame.
- **App side (per device):** Settings → "Streaming" toggle (default on). When
off, the app ignores `message.start`/`message.update` frames and
materializes each reply as a single final message on `message.stop`
(`ChatStore.onMessageStop` already handles a stop without a live bubble).
The gateway keeps streaming — frames are broadcast to all devices, so this
is a display preference like tool verbosity, not a wire flag.
## 5.2 Reasoning (shown *before* the message)
+31
View File
@@ -45,6 +45,34 @@ gateway identity concepts**.
- The gateway's `create_handoff_thread` is used where hermes wants to open a
named thread (e.g. continuable cron).
### 6.3.1 Auto-threading (Telegram topic-mode workflow)
With **Threads ON**, a message sent in a channel's flat lane (no active
thread) gets its **own fresh thread**, the way Telegram topic mode mints a
topic per new conversation — and the **AI names it** instead of the user:
1. The app sends `message.send {…, auto_thread:true}` (only from a flat lane,
with non-empty text, never for slash commands).
2. The gateway mints a thread (`channel.create`-equivalent, `kind:thread`)
under the channel, **named instantly** from the user's opening message via
hermes' session-title derivation (`agent/title_generator.derive_title` —
a deterministic slice of the user's own words, no model call), and
broadcasts `channel.created {auto:true}`.
3. The user echo, the agent turn, and all streaming frames carry the new
`thread_id` — the whole conversation lives in the thread.
4. In the background, the gateway upgrades the name with the model's title
(`agent/title_generator.generate_title`, the `title_generation` auxiliary
task) and broadcasts `channel.renamed`. This is hermes' two-stage session
titling (derived < llm < user) applied to the thread name; failures leave
the derived name in place.
App side: on `channel.created {auto:true}` under the flat lane it is viewing,
the app **jumps into the new thread** and relocates the optimistic pending
bubble from the flat lane into it (the echo arrives in the thread lane).
Follow-ups sent inside the thread stay there; the next flat-lane message
starts another thread. Media-only sends and slash commands stay in the flat
lane (nothing to title / session-scoped, not conversation starters).
## 6.4 User-created channels (for cron delegation)
- **Requirement:** the user creates new channels so **cron job outputs can be
@@ -55,6 +83,9 @@ gateway identity concepts**.
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
directory (rename broadcasts `channel.renamed`; delete is soft — marks
archived, keeps history for search).
- **App affordance for threads:** long-press (touch) / right-click (desktop) a
topic chip → "Rename" (`channel.rename`) or "Delete" (`channel.delete`).
Deleting the open thread falls the app back to the channel's flat lane.
- **Cron targeting** (the key payoff): because the plugin registers
`parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the
`send_message` tool can target any channel/thread:
+46 -3
View File
@@ -93,22 +93,53 @@ app/shared/src/
clarifies) render as **native pickers** from `picker.*` frames (a dialog /
sheet with the options; answer via `picker.select`).
### Streaming (app-controlled)
- **Settings → "Streaming"** toggle (default on). When off, the app ignores
`message.start`/`message.update` frames and shows each reply as a single
final message on `message.stop` (the typing indicator covers the wait).
Per-device display preference — the gateway keeps streaming for other
devices (see `05-streaming.md` §5.1).
### Tool output (app-controlled verbosity)
- `ToolCard` renders `tool.start/progress/end` frames.
- The gateway always supplies the **full** tool data: it forces
`display.platforms.android.tool_progress: verbose` (so the progress line
carries the full args JSON → `tool.start.args`) and captures each completed
call via the `post_tool_call` hook (→ `tool.end` `output_preview` /
`duration` / `ok`). The app decides how much to show.
- **Settings → "Tool detail": Everything / Truncated / Nothing.**
- Everything: name + full args (collapsible) + output preview.
- Truncated (default): `emoji name: "short preview"` one-liner, expandable.
- Nothing: suppress tool frames.
- Everything: cards start expanded — name + full args + output.
- Truncated (default): compact one-liner; tap a card to reveal the full
args + output (what was actually called).
- Nothing: suppress tool cards.
- Spinner while running; ✓/✗ + duration on `tool.end`.
### Reasoning before message
- `ReasoningBlock` (collapsible, "💭 Reasoning" header, monospace body, **copy**
button) rendered **above** the message body from the `reasoning` field.
Matches the reference screenshot.
- **Settings → "Reasoning"** toggle (default on): auto-collapse — long
reasoning blocks start collapsed, short ones expanded. Off: all reasoning
blocks start expanded. Per-device display preference; the per-block
tap-to-toggle always works.
### Intermediate messages
- `commentary` frames → dimmed/smaller bubble, distinct from final answers.
### Message selection + delete
- **Long-press** a message bubble (touch) or **right-click** it (desktop mouse)
enters selection mode: the tapped message is selected (a circular check appears
beside each bubble) and the composer is replaced by a selection toolbar
(count + **Delete** + cancel ✕).
- While selecting, **tap** a bubble to toggle it; the ✕ (or deselecting the last
message) exits selection mode. Only finalized messages are selectable — a
streaming bubble has no final id yet and a pending echo isn't on the server.
- **Delete** → confirm dialog → `message.delete {message_ids:[…]}` for the
current lane. The server removes the message(s) from the outbox (so
`history`/`sync` no longer return them) and broadcasts `message.deleted` to
every device; each device drops them from its cache (the requesting device
also drops them locally for snappy UX). Deleting is idempotent.
### Agent busy / stop / steer
- `agent.busy` → show "thinking…" indicator in chat header (animated dots).
- `agent.idle` → clear indicator.
@@ -122,8 +153,20 @@ app/shared/src/
highlight bar) — matches the reference left sidebar.
- **Thread toggle** in the default chat header: "Threads on/off". On → topic
switcher above the message list (each topic = a `thread_id`).
- **Auto-threading** (Threads on, Telegram topic-mode workflow,
`06-channels-cron-search.md` §6.3.1): a message sent in a channel's flat
lane goes out as `message.send {auto_thread:true}`; the gateway mints a
fresh thread (instant derived name, AI-named a moment later via
`channel.renamed`) and the app jumps into it, relocating the optimistic
bubble from the flat lane.
- **New channel** (FAB / channel-list menu) → `channel.create` → appears in list;
menu offers "Set as cron target".
- **Topic context menu** — long-press a topic chip (touch) or right-click it
(desktop mouse) → "Rename" / "Delete". Rename → `channel.rename` (prefilled
dialog); Delete → confirm → `channel.delete` (soft-delete; the thread leaves
the switcher and, if it was the open lane, the app falls back to the channel's
flat lane). The right-click handler is a skiko `expect`/`actual`
(`iris/ui/ContextMenu.kt`); on touch it is a no-op (long-press covers it).
### Search
- Search bar (chat header or top) with a **scope toggle**: "Search everywhere" /
+9
View File
@@ -32,6 +32,11 @@ without the app (critical for verifying frame shapes early).
- **Push backend selection:** fcm vs ntfy chosen by config; `configured()`
reflects missing creds.
- **Channel directory:** create/rename/set_default; cron target resolution.
- **Auto-threading:** `message.send {auto_thread:true}` in a flat lane mints a
thread (derived name, `channel.created {auto:true}`) and the echo/event
carry the new `thread_id`; no-op with an existing `thread_id`, for slash
commands, or for media-only sends; the LLM upgrade renames the thread
(`channel.renamed`).
- **No `~/.hermes` writes in tests** — use the `_isolate_hermes_home` fixture
pattern (temp `HERMES_HOME`). Profile tests also mock `Path.home()`.
@@ -113,6 +118,10 @@ adb logcat -d > /tmp/logcat.txt
trigger a message → FCM notification appears → tap → syncs + deep-links.
12. **Reconnect/sync:** kill the WS (stop gateway briefly) → restart → app
reconnects → `sync` catches up (no lost/dup messages).
13. **Auto-threading:** Threads on → send a message in the Default channel's
flat lane → a new topic appears (derived name), the app jumps into it, the
reply streams there, and the topic is renamed to the AI's title a moment
later. Slash commands / media-only sends stay in the flat lane.
## 13.5 Debugging tips
+12 -8
View File
@@ -42,6 +42,7 @@
"message.start": { "payload": { "message_id": { "type": "string" }, "role": { "type": "string" } } },
"message.update": { "payload": { "message_id": { "type": "string" }, "text": { "type": "string", "description": "Full current text (app replaces)." } } },
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "ts": { "type": "integer" } } },
"message.deleted": { "description": "The given message(s) were deleted from a chat/thread. Response to a message.delete request (id set) and broadcast to every device so all drop them from their cache; also outboxed so an offline device learns of the deletion on its next sync.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" } } } },
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } },
"tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } },
@@ -49,8 +50,8 @@
"typing": { "payload": { "on": { "type": "boolean" } } },
"notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "channel_deleted", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } },
"channel.list": { "description": "Full channel directory (response to a channel.list request).", "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } },
"channel.created": { "payload": { "$ref": "#/definitions/channel" } },
"channel.renamed": { "description": "Also the response to channel.set_default (carries the full entry incl. is_default).", "payload": { "$ref": "#/definitions/channel" } },
"channel.created": { "description": "May carry auto:true for a thread the gateway minted itself for an incoming message (auto-threading); the name is a derived title, upgraded by a follow-up channel.renamed.", "payload": { "$ref": "#/definitions/channel" } },
"channel.renamed": { "description": "Also the response to channel.set_default / channel.favorite / channel.icon (carries the full entry incl. is_default, favorite, icon, color).", "payload": { "$ref": "#/definitions/channel" } },
"channel.deleted": { "payload": { "chat_id": { "type": "string" } } },
"search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } },
"media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } },
@@ -59,29 +60,34 @@
"error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
"pong": { "payload": { "ts": { "type": "integer" } } },
"sync.done": { "payload": { "cursor": { "type": "integer" } } },
"history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } } },
"media.pull.end": { "payload": { "ok": { "type": "boolean" } } },
"media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } }
},
"app_to_server": {
"hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
"message.send": { "payload": { "text": { "type": "string" }, "reply_to": { "type": "string" }, "media_refs": { "type": "array", "items": { "type": "string" } } } },
"message.send": { "payload": { "text": { "type": "string" }, "reply_to": { "type": "string" }, "media_refs": { "type": "array", "items": { "type": "string" } }, "auto_thread": { "type": "boolean", "description": "Optional, default false. Ask the gateway to mint a fresh thread for this message (auto-threading). Honored only in a channel's flat lane (thread_id null) with non-empty non-slash text; the gateway broadcasts channel.created {auto:true} and the echo/turn carry the new thread_id." } } },
"media.upload.start": { "payload": { "media_ref": { "type": "string" }, "kind": { "$ref": "#/definitions/kind" }, "mime": { "type": "string" }, "size": { "type": "integer" }, "filename": { "type": "string" } } },
"media.upload.end": { "payload": { "media_ref": { "type": "string" }, "sha256": { "type": "string" } } },
"media.pull": { "payload": { "media_id": { "type": "string" } } },
"channel.create": { "payload": { "name": { "type": "string" }, "kind": { "type": "string", "enum": ["channel", "thread"] }, "parent_chat_id": { "type": ["string", "null"] } } },
"channel.rename": { "payload": { "name": { "type": "string" } } },
"channel.set_default": { "payload": {} },
"channel.set_default": { "payload": {} },
"channel.favorite": { "description": "Toggle the cosmetic favorite flag (sorts the channel to the top of the list). Answered by a channel.renamed carrying the full entry.", "payload": { "on": { "type": "boolean" } } },
"channel.icon": { "description": "Set the channel's cosmetic icon (base64 image) and/or avatar color. Omit / null a field to clear it. Answered by a channel.renamed carrying the full entry.", "payload": { "icon": { "type": ["string", "null"], "description": "base64-encoded image (PNG/JPEG), max ~512 KiB." }, "color": { "type": ["string", "null"], "description": "#RRGGBB avatar color override." } } },
"channel.delete": { "payload": {} },
"channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "payload": {} },
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } },
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
"history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } },
"message.delete": { "description": "Delete the given message(s) from a chat/thread. The server removes them from the outbox (so history/sync no longer return them) and broadcasts message.deleted to every device. Idempotent: a message already gone (pruned) still yields a message.deleted broadcast.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" }, "description": "One or more message_id values to delete." } } },
"fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
"ping": { "payload": { "ts": { "type": "integer" } } }
}
},
"definitions": {
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"} } },
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."} } },
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } }
},
"x-planned-frames": [
@@ -91,8 +97,6 @@
{ "name": "picker.approval", "direction": "server_to_app", "note": "Approval picker prompt. Planned, not implemented (approvals arrive as notification)." },
{ "name": "picker.confirm", "direction": "server_to_app", "note": "Confirmation picker prompt. Planned, not implemented." },
{ "name": "picker.select", "direction": "app_to_server", "note": "Picker answer. Planned, not implemented." },
{ "name": "history", "direction": "server_to_app", "note": "Paged history response. Planned, not implemented (catch-up is sync/outbox replay)." },
{ "name": "history", "direction": "app_to_server", "note": "Paged history request. Planned, not implemented (catch-up is sync/outbox replay)." },
{ "name": "commands.catalog", "direction": "server_to_app", "note": "Slash-command catalog. Planned, not implemented." },
{ "name": "commands.catalog", "direction": "app_to_server", "note": "Slash-command catalog request. Planned, not implemented." },
{ "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented." },
@@ -105,7 +109,7 @@
],
"reliability": {
"ordering": "Per-connection (TCP/WS). message.update for a message_id is monotonic; app may coalesce to latest.",
"never_dropped": ["message", "message.stop", "tool.end", "notification", "channel.*", "search.results", "error"],
"never_dropped": ["message", "message.stop", "message.deleted", "tool.end", "notification", "channel.*", "search.results", "error"],
"coalescable_under_backpressure": ["message.update", "tool.progress"],
"offline": "Undelivered frames go to the outbox; replayed by sync. Terminal frames always outboxed."
}
+487 -13
View File
@@ -58,12 +58,14 @@ Or via environment variables (overrides config.yaml; secrets live in .env):
"""
import asyncio
import json
import logging
import os
import re
import threading
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
@@ -185,6 +187,75 @@ def _reset_reasoning() -> None:
_reasoning_flushed.clear()
# ---------------------------------------------------------------------------
# M2 — tool-result capture (post_tool_call hook)
#
# The gateway renders tool *progress* lines to the platform but never streams
# the tool *output* (it is the agent's concern, persisted to history, not
# presentation). To let the app show the full call + result on demand
# (Settings → Tool detail), we capture each completed tool call via the
# ``post_tool_call`` hook and attach it to the ``tool.end`` frame.
#
# Global FIFO (like the reasoning buffer): a personal android gateway serves
# one active turn at a time, and records are matched to the open tool by name
# in completion order. Bounded so a runaway turn can't grow it without limit.
# ---------------------------------------------------------------------------
_tool_results: "deque[Dict[str, Any]]" = deque()
_tool_results_lock = threading.Lock()
_MAX_TOOL_RESULTS = 200
_MAX_OUTPUT_PREVIEW = 8000
def _on_post_tool_call(**kwargs: Any) -> None:
"""Plugin hook: capture a completed tool call's result + timing."""
result = kwargs.get("result")
record = {
"tool_name": kwargs.get("tool_name") or "",
"result": (str(result) if result is not None else "")[:_MAX_OUTPUT_PREVIEW],
"duration_ms": kwargs.get("duration_ms") or 0,
"status": kwargs.get("status") or "ok",
}
with _tool_results_lock:
_tool_results.append(record)
while len(_tool_results) > _MAX_TOOL_RESULTS:
_tool_results.popleft()
def _take_tool_result(tool_name: str) -> Optional[Dict[str, Any]]:
"""Pop the first completed record matching *tool_name* (FIFO), else None."""
if not tool_name:
return None
with _tool_results_lock:
for i, rec in enumerate(_tool_results):
if rec["tool_name"] == tool_name:
del _tool_results[i]
return rec
return None
def _reset_tool_results() -> None:
"""Clear captured records (turn boundary — drop anything unconsumed)."""
with _tool_results_lock:
_tool_results.clear()
def _tool_end_fields(tool_name: str) -> Dict[str, Any]:
"""Build the ``tool.end`` enrichment (ok/duration/output_preview) from the
captured hook record for *tool_name*; empty dict when none is available
(e.g. tool_progress off, or the call came from another session)."""
rec = _take_tool_result(tool_name)
if rec is None:
return {}
fields: Dict[str, Any] = {
"ok": rec["status"] == "ok",
"output_preview": rec["result"] or None,
}
if rec["duration_ms"]:
fields["duration"] = round(rec["duration_ms"] / 1000.0, 3)
return fields
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
@@ -283,6 +354,25 @@ def _thread_id_from_metadata(metadata: Optional[Dict[str, Any]]) -> Optional[str
return None
def _derive_thread_name(text: str) -> str:
"""Instant auto-thread name from the user's opening message (no model).
Reuses hermes' session-title derivation (``agent/title_generator.py``):
a deterministic slice of the user's own words, so the thread is named the
moment it is created. The LLM upgrade (``_schedule_thread_title_upgrade``)
replaces it moments later — the same two-stage titling hermes uses for
sessions (derived < llm < user).
"""
try:
from agent.title_generator import derive_title
title = derive_title(text)
except Exception:
logger.debug("Thread name derivation failed", exc_info=True)
title = None
return (title or "").strip() or "New thread"
def _strip_streaming_cursor(text: str) -> str:
if text and text.endswith(_STREAMING_CURSOR):
return text[: -len(_STREAMING_CURSOR)]
@@ -406,6 +496,50 @@ def _extract_code_block(content: str) -> Optional[str]:
return None
def _extract_verbose_args(line: str, content: str) -> Optional[Dict[str, Any]]:
"""Recover the full args dict from a verbose tool line, else ``None``.
In verbose mode the gateway renders ``<emoji> <name>(keys)`` on one line
and the full args JSON on the line that follows. When *line* is such a
header, return the parsed JSON object from the following line.
"""
parts = line.strip().split(None, 1)
if len(parts) < 2 or not _TOOL_NAME_ARGS_RE.match(parts[1]):
return None
lines = content.splitlines()
for i, ln in enumerate(lines):
if ln.strip() != line.strip():
continue
for follow in lines[i + 1:]:
follow = follow.strip()
if not follow:
continue
if follow.startswith("{"):
try:
obj = json.loads(follow)
return obj if isinstance(obj, dict) else None
except Exception:
return None
return None # next non-empty line is not the args JSON
return None
def _short_preview_from_args(args: Dict[str, Any], cap: int = 60) -> Optional[str]:
"""Derive a short one-line preview from a verbose args dict.
The verbose line carries no explicit preview, so the Truncated display
would otherwise lose its one-liner. Use the first non-empty string value
(whitespace-collapsed, capped) as a stand-in.
"""
if not isinstance(args, dict):
return None
for value in args.values():
if isinstance(value, str) and value.strip():
s = " ".join(value.split())
return s[: cap - 1] + "…" if len(s) > cap else s
return None
def _is_tool_progress(content: str) -> bool:
"""Heuristic: does *content* look like gateway tool-progress line(s)?
@@ -426,6 +560,23 @@ def _is_tool_progress(content: str) -> bool:
return _parse_tool_line(first) is not None
def _is_gateway_lifecycle_notice(content: str) -> bool:
"""True for hermes gateway lifecycle notices (restart / shutdown / online).
These are system notices, not tool progress. Their leading ⚠️/♻️ emoji
would otherwise trip the tool-line heuristic and render them as a
never-completing tool card (an endless spinner, since no ``tool.end``
ever arrives for a notice that is not a real tool).
"""
if not content:
return False
c = content.strip()
return any(
marker in c
for marker in ("Gateway restarting", "Gateway shutting down", "Gateway online")
)
@dataclass
class _TurnState:
"""Per-chat turn state for outbound frame classification (M2)."""
@@ -439,6 +590,9 @@ class _TurnState:
tool_index: int = 0
# Index of the most recently started tool (awaiting tool.end).
open_tool_index: Optional[int] = None
# Name of the most recently started tool (matches the post_tool_call
# record when the tool completes, so tool.end can carry its output).
open_tool_name: Optional[str] = None
# Tool lines already emitted as tool.start (dedup across edits).
seen_tool_lines: set = field(default_factory=set)
@@ -601,6 +755,46 @@ async def _standalone_send(
}
# ---------------------------------------------------------------------------
# Verbose tool progress (full args on the progress line)
# ---------------------------------------------------------------------------
def _ensure_verbose_tool_progress() -> None:
"""Ensure the android platform renders tool progress in ``verbose`` mode.
Verbose mode makes the gateway's tool-progress line carry the FULL
argument JSON (not just a ~40-char preview), which the adapter parses
into the ``tool.start`` frame's ``args`` field; the app then decides how
much to show (Settings → Tool detail). The tool *output* is captured
separately via the ``post_tool_call`` hook (verbose mode does not stream
it).
Best-effort and idempotent: writes
``display.platforms.android.tool_progress: verbose`` to config.yaml only
when it isn't already set. The gateway's config cache is mtime-keyed, so
the write takes effect on the next turn without a restart. Never raises.
"""
try:
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly() or {}
display = cfg.get("display") or {}
platforms = display.get("platforms") or {}
android = platforms.get("android") or {}
if android.get("tool_progress") == "verbose":
return # already set
from utils import atomic_roundtrip_yaml_update
atomic_roundtrip_yaml_update(
get_hermes_home() / "config.yaml",
"display.platforms.android.tool_progress",
"verbose",
)
logger.info("android: set display.platforms.android.tool_progress=verbose")
except Exception:
logger.debug("android: could not ensure verbose tool_progress", exc_info=True)
# ---------------------------------------------------------------------------
# Interactive setup (hermes gateway setup flow)
# ---------------------------------------------------------------------------
@@ -654,6 +848,10 @@ def interactive_setup() -> None:
print_info(f"Pairing URL: {qr_payload(host or DEFAULT_HOST, _parse_port(port), token)}")
print_info(f"Server URL: {url}")
# Always render tool progress verbosely so the app receives the full tool
# call args (it decides how much to show via Settings → Tool detail).
_ensure_verbose_tool_progress()
print_success("Android configuration saved to ~/.hermes/.env")
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
@@ -671,10 +869,21 @@ class AndroidAdapter(BasePlatformAdapter):
``MessageEvent``s for ``handle_message()``.
"""
# WS has no message-size limit. The stream consumer resolves its per-chat
# chunking budget via ``max_message_length_for_chat`` -> this attribute
# (defaulting to 4096 when unset), which would split long replies — and
# complete HTML artifacts — across multiple fence-reopened messages. A
# large cap disables that chunking so a reply arrives as a single message.
MAX_MESSAGE_LENGTH = 1_000_000
def __init__(self, config, **kwargs):
platform = Platform("android")
super().__init__(config=config, platform=platform)
# Ensure verbose tool progress (full args on the progress line) so the
# app can show the full tool call on demand. Best-effort; idempotent.
_ensure_verbose_tool_progress()
extra = getattr(config, "extra", {}) or {}
# Connection settings (env vars override config.yaml)
@@ -973,7 +1182,10 @@ class AndroidAdapter(BasePlatformAdapter):
return SendResult(success=True, message_id=message_id)
# 4. Tool progress (first tool bubble of an editable line buffer).
if _is_tool_progress(content):
# Gateway lifecycle notices (restart/shutdown/online) are system
# notices, not tool progress — skip the tool-line heuristic so they
# fall through to commentary instead of a never-completing card.
if not _is_gateway_lifecycle_notice(content) and _is_tool_progress(content):
return await self._emit_tool_lines(chat_id, content, state, thread_id, is_edit=False)
# 5. Commentary (interim assistant beat).
@@ -1094,38 +1306,59 @@ class AndroidAdapter(BasePlatformAdapter):
parsed = self._parse_tool_line_or_block(line, content)
if parsed is None:
continue
name, preview = parsed
# A new tool begins: close the previously-open one.
name, preview, args = parsed
# A new tool begins: close the previously-open one, attaching the
# output/duration/ok captured by the post_tool_call hook.
if state.open_tool_index is not None:
extra = _tool_end_fields(state.open_tool_name or "")
await self._broadcast_or_log(
chat_id,
protocol.tool_end(chat_id, state.open_tool_index, "", ok=True, thread_id=thread_id),
protocol.tool_end(
chat_id, state.open_tool_index, state.open_tool_name or "",
ok=extra.get("ok", True),
duration=extra.get("duration"),
output_preview=extra.get("output_preview"),
thread_id=thread_id,
),
)
state.tool_index += 1
state.open_tool_index = state.tool_index
state.open_tool_name = name
await self._broadcast_or_log(
chat_id,
protocol.tool_start(
chat_id, state.tool_index, name,
preview=preview, thread_id=thread_id,
preview=preview, args=args, thread_id=thread_id,
),
)
return SendResult(success=True, message_id=message_id)
@staticmethod
def _parse_tool_line_or_block(line: str, content: str) -> Optional[Tuple[str, Optional[str]]]:
"""Parse a tool line, expanding a terminal code block to its command."""
def _parse_tool_line_or_block(
line: str, content: str
) -> Optional[Tuple[str, Optional[str], Optional[Dict[str, Any]]]]:
"""Parse a tool line into ``(name, preview, args)``.
Expands a terminal code block to its command, and a verbose header
(``<emoji> <name>(keys)``) to its full args JSON (the JSON sits on the
following line). ``args`` is ``None`` unless the line is a verbose
header with a parseable JSON body.
"""
parsed = _parse_tool_line(line)
if parsed is not None:
if parsed is None:
return None
name, preview = parsed
# Terminal code block: the command lives in the fenced lines that
# follow the "<emoji> terminal" head line.
if name == "terminal" and preview is None and "```" in content:
cmd = _extract_code_block(content)
if cmd:
return name, cmd
return parsed
return None
return name, cmd, None
# Verbose mode: recover the full args from the following JSON line.
args = _extract_verbose_args(line, content)
if args is not None and preview is None:
preview = _short_preview_from_args(args)
return name, preview, args
async def _close_open_tool(
self, chat_id: str, state: _TurnState, thread_id: Optional[str]
@@ -1137,11 +1370,19 @@ class AndroidAdapter(BasePlatformAdapter):
tool it was waiting on has returned).
"""
if state.open_tool_index is not None:
extra = _tool_end_fields(state.open_tool_name or "")
await self._broadcast_or_log(
chat_id,
protocol.tool_end(chat_id, state.open_tool_index, "", ok=True, thread_id=thread_id),
protocol.tool_end(
chat_id, state.open_tool_index, state.open_tool_name or "",
ok=extra.get("ok", True),
duration=extra.get("duration"),
output_preview=extra.get("output_preview"),
thread_id=thread_id,
),
)
state.open_tool_index = None
state.open_tool_name = None
def _reset_tool_state(self, state: _TurnState) -> None:
"""Clear per-turn tool bookkeeping (called at turn finalization)."""
@@ -1149,6 +1390,10 @@ class AndroidAdapter(BasePlatformAdapter):
state.seen_tool_lines = set()
state.tool_index = 0
state.open_tool_index = None
state.open_tool_name = None
# Drop any captured tool results not consumed by a tool.end this turn
# (e.g. tool_progress off) so they can't leak into the next turn.
_reset_tool_results()
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
delivered = await self._ws_server.broadcast(frame)
@@ -1465,6 +1710,33 @@ class AndroidAdapter(BasePlatformAdapter):
if not isinstance(reply_to, str) or not reply_to.strip():
reply_to = None
# Auto-threading (the app's Threads setting, docs/06 §6.3): a message
# in a channel's flat lane gets its own fresh thread, the way Telegram
# topic mode mints a topic per new conversation. The thread is named
# instantly from the user's opening message (derived title) and the
# LLM upgrades the name in the background. The user echo, the agent
# turn, and all streaming frames then carry the new thread_id.
# Skipped for slash commands (session-scoped, not conversation
# starters) and replies (they continue where the user is).
auto_thread = payload.get("auto_thread") is True
if (
auto_thread
and thread_id is None
and text.strip()
and not text.lstrip().startswith("/")
and reply_to is None
):
entry = self._channels.create(
name=_derive_thread_name(text),
kind="thread",
parent_chat_id=chat_id,
)
thread_id = entry["chat_id"]
# Bare broadcast (like channel.create): the directory is
# re-served on hello.ack, so no outbox entry is needed.
await self._ws_server.broadcast(protocol.channel_created(entry, auto=True))
self._schedule_thread_title_upgrade(entry["chat_id"], text)
# M4: resolve media refs (single-use; unknown ref -> error).
media_urls: List[str] = []
media_types: List[str] = []
@@ -1494,6 +1766,12 @@ class AndroidAdapter(BasePlatformAdapter):
# Echo to all devices: the sender confirms (server-assigned id),
# other devices see the message too (single-user, multi-device).
# Routed through _broadcast_or_log (not a bare broadcast) so the echo
# is appended to the outbox: the app's ChatStore is in-memory only, so
# after a process death / activity recreation the only way the user's
# own message is restored is via the sync replay. Without this, user
# messages vanish on reconnect while bot messages (already parked)
# survive.
message_id = f"m_{uuid.uuid4().hex[:16]}"
echo = protocol.message(
chat_id=chat_id,
@@ -1505,7 +1783,7 @@ class AndroidAdapter(BasePlatformAdapter):
reply_to=reply_to,
ts=int(time.time() * 1000),
)
await self._ws_server.broadcast(echo)
await self._broadcast_or_log(chat_id, echo)
# Refs are consumed by this message (no replay).
for ref in media_refs:
self._media.pop_inbound(ref)
@@ -1554,6 +1832,48 @@ class AndroidAdapter(BasePlatformAdapter):
protocol.read_receipt(chat_id, message_id),
)
def _schedule_thread_title_upgrade(self, thread_id: str, text: str) -> None:
"""Upgrade an auto-created thread's name with the model's title.
Stage 2 of hermes' two-stage session titling (``agent/title_generator
.py``): the thread was created with an instant derived name; this
background call on the ``title_generation`` auxiliary task replaces it
with the model's title and broadcasts ``channel.renamed``. Best-effort
— any failure (config, model, network) leaves the derived name in
place, and a thread the user already renamed or archived is untouched.
"""
loop = asyncio.get_running_loop()
def _work() -> None:
try:
from agent.title_generator import generate_title
title = generate_title(text)
except Exception:
logger.debug("Thread title upgrade failed", exc_info=True)
return
if not title:
return
entry = self._channels.get(thread_id)
if entry is None or entry.get("archived"):
return
if (entry.get("name") or "") == title:
return
renamed = self._channels.rename(thread_id, title)
if renamed is None:
return
try:
asyncio.run_coroutine_threadsafe(
self._ws_server.broadcast(protocol.channel_renamed(renamed)),
loop,
)
except Exception:
logger.debug("Thread title rename broadcast failed", exc_info=True)
threading.Thread(
target=_work, daemon=True, name="android-thread-title"
).start()
# ── M4: inbound media (app -> agent) ──────────────────────────────────
#
# ``media.upload.start`` -> raw binary frames (one at a time per
@@ -1787,6 +2107,59 @@ class AndroidAdapter(BasePlatformAdapter):
resp.id = frame.id
await self._ws_server.broadcast(resp)
async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, "channel.favorite requires chat_id", id=frame.id),
)
return
on = bool(frame.payload.get("on"))
entry = self._channels.set_favorite(chat_id, on)
if entry is None:
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
)
return
# Reuse the renamed event shape: it carries the full entry (incl. the
# new favorite flag) so every device reconciles the change.
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._ws_server.broadcast(resp)
async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, "channel.icon requires chat_id", id=frame.id),
)
return
payload = frame.payload
icon = payload.get("icon")
icon = icon if isinstance(icon, str) and icon else None
color = payload.get("color")
color = color if isinstance(color, str) and color else None
# Guard against a runaway base64 blob (a channel icon is small).
if icon is not None and len(icon) > 512 * 1024:
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_UNSUPPORTED, "channel icon too large", id=frame.id),
)
return
entry = self._channels.set_icon(chat_id, icon, color)
if entry is None:
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
)
return
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._ws_server.broadcast(resp)
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
@@ -1875,6 +2248,100 @@ class AndroidAdapter(BasePlatformAdapter):
done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id)
await self._ws_server.send_to(device_id, done)
# ── Full message history (initial channel open / scroll-up) ───────────
async def on_history(self, frame: protocol.Frame, device_id: str) -> None:
"""Handle an inbound ``history`` request.
``sync`` only replays the outbox delta since the device's cursor, so
after a process death the app's in-memory ChatStore is empty and the
delta does not cover older messages. ``history`` loads the full
message list for a chat/thread (reconstructed from the outbox log) so
the app can populate the view on first open / restart.
"""
payload = frame.payload
chat_id = frame.chat_id or payload.get("chat_id")
logger.info("android: history request from %s chat_id=%r", device_id, chat_id)
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_UNSUPPORTED, "history requires a chat_id", id=frame.id),
)
return
chat_id = chat_id.strip()
thread_id = frame.thread_id or payload.get("thread_id")
if not isinstance(thread_id, str) or not thread_id.strip():
thread_id = None
before = payload.get("before_message_id")
if not isinstance(before, str) or not before.strip():
before = None
limit_raw = payload.get("limit")
try:
limit = int(limit_raw) if limit_raw is not None else 50
except (TypeError, ValueError):
limit = 50
page = self._outbox.history(
chat_id,
thread_id=thread_id,
before_message_id=before,
limit=limit,
)
resp = protocol.history(
chat_id,
page["messages"],
page["has_more"],
thread_id=thread_id,
oldest_message_id=page["oldest_message_id"],
id=frame.id,
)
await self._ws_server.send_to(device_id, resp)
# ── Message deletion (app -> agent) ───────────────────────────────────
async def on_message_delete(self, frame: protocol.Frame, device_id: str) -> None:
"""Handle an inbound ``message.delete`` request.
Removes the requested message(s) from the outbox (so ``history`` and
``sync`` no longer return them) and broadcasts ``message.deleted`` to
every device (outboxed too, so an offline device learns of the
deletion on its next ``sync``). Deleting is idempotent: a message that
is already gone (pruned by retention) simply yields 0 removed rows,
and the ``message.deleted`` broadcast is still emitted so live caches
drop it.
"""
payload = frame.payload
chat_id = frame.chat_id or payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, "message.delete requires chat_id", id=frame.id),
)
return
chat_id = chat_id.strip()
thread_id = frame.thread_id or payload.get("thread_id")
if not isinstance(thread_id, str) or not thread_id.strip():
thread_id = None
message_ids = payload.get("message_ids")
if not isinstance(message_ids, list):
message_ids = [payload.get("message_id")] if payload.get("message_id") else []
message_ids = [m for m in message_ids if isinstance(m, str) and m.strip()]
if not message_ids:
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_UNSUPPORTED, "message.delete requires message_ids", id=frame.id),
)
return
removed = 0
for mid in message_ids:
removed += self._outbox.delete_message(chat_id, mid, thread_id=thread_id)
logger.info(
"android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s",
device_id, chat_id, thread_id, message_ids, removed,
)
resp = protocol.message_deleted(chat_id, message_ids, thread_id=thread_id)
resp.id = frame.id
await self._broadcast_or_log(chat_id, resp)
# ── M5: push token registration ───────────────────────────────────────
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
@@ -2123,6 +2590,13 @@ def register(ctx):
ctx.register_hook("on_stream_delta", _on_stream_delta)
except Exception:
logger.debug("android: on_stream_delta hook registration failed", exc_info=True)
# M2: capture each completed tool call's result + timing so the tool.end
# frame can carry the output (the gateway never streams tool output to
# platforms). The app shows it on demand (Settings → Tool detail).
try:
ctx.register_hook("post_tool_call", _on_post_tool_call)
except Exception:
logger.debug("android: post_tool_call hook registration failed", exc_info=True)
ctx.register_platform(
name="android",
label="Android",
+50 -3
View File
@@ -64,10 +64,25 @@ class ChannelDirectory:
parent_chat_id TEXT,
is_default INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
created REAL NOT NULL DEFAULT 0
created REAL NOT NULL DEFAULT 0,
favorite INTEGER NOT NULL DEFAULT 0,
icon TEXT,
color TEXT
)
"""
)
# Migrate existing DBs: add the cosmetic columns if missing.
existing = {
row[1] for row in self._conn.execute("PRAGMA table_info(channels)")
}
if "favorite" not in existing:
self._conn.execute(
"ALTER TABLE channels ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0"
)
if "icon" not in existing:
self._conn.execute("ALTER TABLE channels ADD COLUMN icon TEXT")
if "color" not in existing:
self._conn.execute("ALTER TABLE channels ADD COLUMN color TEXT")
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS counters (
@@ -206,6 +221,35 @@ class ChannelDirectory:
self._conn.commit()
return self.get(chat_id)
def set_favorite(self, chat_id: str, on: bool) -> Optional[Dict[str, Any]]:
"""Toggle the cosmetic favorite flag (sorts to the top of the list)."""
with self._lock:
cur = self._conn.execute(
"UPDATE channels SET favorite = ? WHERE chat_id = ? AND archived = 0",
(1 if on else 0, chat_id),
)
self._conn.commit()
if cur.rowcount == 0:
return None
return self.get(chat_id)
def set_icon(self, chat_id: str, icon: Optional[str], color: Optional[str]) -> Optional[Dict[str, Any]]:
"""Set the channel's cosmetic icon (base64 image) and/or avatar color.
``icon`` is a base64-encoded image (or ``None`` to clear it); ``color``
is a ``#RRGGBB`` hex string (or ``None`` to clear it). Both are purely
cosmetic and independent of the name / default flag.
"""
with self._lock:
cur = self._conn.execute(
"UPDATE channels SET icon = ?, color = ? WHERE chat_id = ? AND archived = 0",
(icon, color, chat_id),
)
self._conn.commit()
if cur.rowcount == 0:
return None
return self.get(chat_id)
def delete(self, chat_id: str) -> Optional[Dict[str, Any]]:
"""Soft-delete (archive) a channel. History stays for search.
@@ -234,11 +278,11 @@ class ChannelDirectory:
return _row_to_entry(row) if row else None
def list(self, include_archived: bool = False) -> List[Dict[str, Any]]:
"""Directory listing. Default channel first, then creation order."""
"""Directory listing. Default first, then favorites, then creation order."""
sql = "SELECT * FROM channels"
if not include_archived:
sql += " WHERE archived = 0"
sql += " ORDER BY is_default DESC, created ASC"
sql += " ORDER BY is_default DESC, favorite DESC, created ASC"
with self._lock:
rows = self._conn.execute(sql).fetchall()
return [_row_to_entry(r) for r in rows]
@@ -318,6 +362,9 @@ def _row_to_entry(row: sqlite3.Row) -> Dict[str, Any]:
"is_default": bool(row["is_default"]),
"archived": bool(row["archived"]),
"created": row["created"],
"favorite": bool(row["favorite"]),
"icon": row["icon"],
"color": row["color"],
}
+157
View File
@@ -169,6 +169,163 @@ class Outbox:
)
return out
# ── history (full message history for a chat/thread) ──────────────────
def history(
self,
chat_id: str,
thread_id: Optional[str] = None,
before_message_id: Optional[str] = None,
limit: int = 50,
) -> Dict[str, Any]:
"""Final messages for a chat/thread, for the ``history`` frame.
Reconstructs the message list from the outbox log: a final message is
either a standalone ``message`` frame (user echo / non-streaming
assistant) or a ``message.stop`` frame (streaming assistant final).
Intermediate frames (``message.start``/``message.update``, tool,
commentary, notification, …) are skipped. Messages are deduplicated by
``message_id`` and returned oldest → newest.
Pagination: ``before_message_id`` returns the page of messages older
than that id (the newest page when omitted). Returns
``{messages, has_more, oldest_message_id}``.
"""
limit = max(1, min(int(limit or 50), 200))
with self._lock:
rows = self._conn.execute(
"SELECT cursor, frame FROM outbox WHERE chat_id = ? "
"ORDER BY cursor ASC",
(chat_id,),
).fetchall()
final: List[Dict[str, Any]] = []
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if thread_id is not None and frame.get("thread_id") != thread_id:
continue
ftype = frame.get("type")
payload = frame.get("payload")
if not isinstance(payload, dict):
continue
if ftype == "message":
role = payload.get("role")
if role not in ("user", "assistant"):
continue
final.append(
{
"cursor": int(r["cursor"]),
"message_id": payload.get("message_id"),
"role": role,
"text": payload.get("text", ""),
"reasoning": payload.get("reasoning"),
"model": payload.get("model"),
"tokens": payload.get("tokens"),
"ts": payload.get("ts"),
"media": payload.get("media"),
}
)
elif ftype == "message.stop":
final.append(
{
"cursor": int(r["cursor"]),
"message_id": payload.get("message_id"),
"role": "assistant",
"text": payload.get("final_text", ""),
"reasoning": payload.get("reasoning"),
"model": payload.get("model"),
"tokens": payload.get("tokens"),
"ts": payload.get("ts"),
"media": None,
}
)
# Deduplicate by message_id (keep the latest occurrence), keep order.
by_id: Dict[str, Dict[str, Any]] = {}
for m in final:
mid = m.get("message_id")
if mid:
by_id[mid] = m
ordered = sorted(by_id.values(), key=lambda m: m["cursor"])
# Paginate: messages older than ``before_message_id`` (newest page when
# omitted / not found — a pruned anchor falls back to the newest page).
if before_message_id:
idx = next(
(i for i, m in enumerate(ordered) if m["message_id"] == before_message_id),
None,
)
pool = ordered[:idx] if idx is not None else ordered
else:
pool = ordered
has_more = len(pool) > limit
page = pool[-limit:] if has_more else list(pool)
oldest_message_id = page[0]["message_id"] if page else None
for m in page:
m.pop("cursor", None)
# Omit absent optional fields (the app's serializer treats a
# missing key as its default, but a JSON ``null`` for a
# non-nullable field like ``media`` would fail to parse).
for key in ("reasoning", "model", "tokens", "ts", "media"):
if m.get(key) is None:
m.pop(key, None)
return {
"messages": page,
"has_more": has_more,
"oldest_message_id": oldest_message_id,
}
# ── message deletion ──────────────────────────────────────────────────
def delete_message(
self,
chat_id: str,
message_id: str,
thread_id: Optional[str] = None,
) -> int:
"""Remove every outbox frame belonging to *message_id* in *chat_id*.
A message can span several frames (``message`` / ``message.start`` /
``message.update`` / ``message.stop`` / ``media.offer`` /
``commentary``); all of them are removed so neither ``history`` nor a
``sync`` replay can resurrect the message. The delete is scoped to the
exact lane: a flat-lane delete (``thread_id=None``) matches only frames
with no ``thread_id``, and a thread delete matches only that thread's
frames (a ``message_id`` is unique to one lane, so this is a safety
net, not a filter that drops real frames). Returns the number of rows
removed (0 when the message is not in the outbox — e.g. already pruned
by retention).
"""
if not message_id:
return 0
with self._lock:
rows = self._conn.execute(
"SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall()
cursors: List[int] = []
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if frame.get("thread_id") != thread_id:
continue
payload = frame.get("payload")
if isinstance(payload, dict) and payload.get("message_id") == message_id:
cursors.append(int(r["cursor"]))
if not cursors:
return 0
placeholders = ",".join("?" * len(cursors))
self._conn.execute(
f"DELETE FROM outbox WHERE cursor IN ({placeholders})", cursors
)
self._conn.commit()
return len(cursors)
# ── retention ─────────────────────────────────────────────────────────
def _maybe_prune(self) -> None:
+86 -3
View File
@@ -42,6 +42,10 @@ TYPE_MESSAGE_START = "message.start"
TYPE_MESSAGE_UPDATE = "message.update"
TYPE_MESSAGE_STOP = "message.stop"
# Message deletion (app requests; broadcast to all devices)
TYPE_MESSAGE_DELETE = "message.delete"
TYPE_MESSAGE_DELETED = "message.deleted"
# Tool activity (M2)
TYPE_TOOL_START = "tool.start"
TYPE_TOOL_PROGRESS = "tool.progress"
@@ -54,6 +58,8 @@ TYPE_COMMENTARY = "commentary"
TYPE_CHANNEL_CREATE = "channel.create"
TYPE_CHANNEL_RENAME = "channel.rename"
TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
TYPE_CHANNEL_FAVORITE = "channel.favorite"
TYPE_CHANNEL_ICON = "channel.icon"
TYPE_CHANNEL_DELETE = "channel.delete"
TYPE_CHANNEL_CREATED = "channel.created"
TYPE_CHANNEL_RENAMED = "channel.renamed"
@@ -68,6 +74,9 @@ TYPE_SEARCH_RESULTS = "search.results"
TYPE_SYNC = "sync"
TYPE_SYNC_DONE = "sync.done"
# Full message history (initial channel open / scroll-up pagination)
TYPE_HISTORY = "history"
# Media (M4)
TYPE_MEDIA_UPLOAD_START = "media.upload.start"
TYPE_MEDIA_UPLOAD_END = "media.upload.end"
@@ -427,12 +436,27 @@ def _channel_payload(entry: Dict[str, Any]) -> Dict[str, Any]:
payload["is_default"] = True
if entry.get("archived"):
payload["archived"] = True
if entry.get("favorite"):
payload["favorite"] = True
if entry.get("icon"):
payload["icon"] = entry["icon"]
if entry.get("color"):
payload["color"] = entry["color"]
return payload
def channel_created(entry: Dict[str, Any]) -> Frame:
"""Broadcast: a channel/thread was created."""
return Frame(type=TYPE_CHANNEL_CREATED, payload=_channel_payload(entry))
def channel_created(entry: Dict[str, Any], auto: bool = False) -> Frame:
"""Broadcast: a channel/thread was created.
``auto=True`` marks a thread the gateway minted itself for an incoming
message (auto-threading, docs/06 §6.3): the app jumps into it and the
name is an instant derived title, upgraded by the LLM via a follow-up
``channel.renamed``.
"""
payload = _channel_payload(entry)
if auto:
payload["auto"] = True
return Frame(type=TYPE_CHANNEL_CREATED, payload=payload)
def channel_renamed(entry: Dict[str, Any]) -> Frame:
@@ -484,6 +508,65 @@ def sync_done(cursor: int, *, id: Optional[int] = None) -> Frame:
return Frame(type=TYPE_SYNC_DONE, id=id, payload={"cursor": cursor})
# ---------------------------------------------------------------------------
# History frame (full message history for a chat/thread)
# ---------------------------------------------------------------------------
def history(
chat_id: str,
messages: List[Dict[str, Any]],
has_more: bool,
*,
thread_id: Optional[str] = None,
oldest_message_id: Optional[str] = None,
id: Optional[int] = None,
) -> Frame:
"""Response to a ``history`` request: a page of final messages for a
chat/thread, ordered oldest → newest. ``has_more`` signals older pages
exist; ``oldest_message_id`` is the ``before_message_id`` for the next
(older) page."""
payload: Dict[str, Any] = {
"messages": messages,
"has_more": has_more,
}
if oldest_message_id:
payload["oldest_message_id"] = oldest_message_id
return Frame(
type=TYPE_HISTORY,
id=id,
chat_id=chat_id,
thread_id=thread_id,
payload=payload,
)
# ---------------------------------------------------------------------------
# Message deletion frames
# ---------------------------------------------------------------------------
def message_deleted(
chat_id: str,
message_ids: List[str],
*,
thread_id: Optional[str] = None,
id: Optional[int] = None,
) -> Frame:
"""Broadcast: the given message(s) were deleted from a chat/thread.
Carried by the response to a ``message.delete`` request (``id`` set) and
broadcast to every device so all of them drop the message(s) from their
cache. Also outboxed, so a device that was offline learns of the deletion
on its next ``sync``.
"""
return Frame(
type=TYPE_MESSAGE_DELETED,
id=id,
chat_id=chat_id,
thread_id=thread_id,
payload={"message_ids": list(message_ids)},
)
# ---------------------------------------------------------------------------
# Push / notification frames (M5)
# ---------------------------------------------------------------------------
+8
View File
@@ -370,6 +370,10 @@ class WsServer:
await self._adapter.on_channel_rename(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
await self._adapter.on_channel_set_default(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_FAVORITE:
await self._adapter.on_channel_favorite(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_ICON:
await self._adapter.on_channel_icon(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_DELETE:
await self._adapter.on_channel_delete(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_LIST:
@@ -378,6 +382,10 @@ class WsServer:
await self._adapter.on_search(frame, device_id)
elif frame.type == protocol.TYPE_SYNC:
await self._adapter.on_sync(frame, device_id)
elif frame.type == protocol.TYPE_HISTORY:
await self._adapter.on_history(frame, device_id)
elif frame.type == protocol.TYPE_MESSAGE_DELETE:
await self._adapter.on_message_delete(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_START:
await self._adapter.on_media_upload_start(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END:
File renamed without changes.