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

This commit is contained in:
ARIA committed 2026-08-20 23:29:43 +02:00
1 parent a8920d8d26
commit f8effb4152
6 files changed
+330 -177

No files matched your search

-172
View File
@@ -1,172 +0,0 @@
# Hand-off: Gateway restart UX (spinner + "back online" message)
> This document is a hand-off. A previous attempt at this task is **incomplete
> and leaves the chat in a broken state**. Read this fully before touching code.
## 1. Original task (from the user)
On **gateway restart** the Android/desktop app had two problems:
1. A **spinner spins endlessly** (a tool card that never completes).
2. There is **no "gateway back online" message**.
The user wants the standard hermes routine (same wording on all apps):
```
⚠️ Gateway restarting — Your current task will be interrupted. Send any message after restart and I'll try to resume where you left off.
```
```
♻️ Gateway online — Hermes is back and ready.
```
## 2. Architecture (critical context)
Three layers, only two are editable:
- **`hermes-agent/`** — READ-ONLY research reference (git-ignored, pre-commit
hook blocks commits). The hermes **core** lives here. Do NOT modify it.
- **`gateway-plugin/`** — the editable Python `android` platform plugin
(symlinked into `~/.hermes/plugins/android`). `adapter.py` is the outbound
frame classifier. This is where the gateway turns core `adapter.send()` calls
into WS frames.
- **`app/`** — Compose Multiplatform (Kotlin). `:shared` holds most code
(`jvmMain` is shared by android + desktop). `IrisController` routes frames
into `ChatStore`; `ChatScreen` renders.
### How the hermes core signals a restart (verified in logs + code)
- On **shutdown/restart** the core calls `adapter.send(home_channel, "⚠️ Gateway
restarting|shutting down — …")`. The android adapter classifies this into a
frame. The core **does** send this to the android home channel (log line:
`Sent shutdown notification to home channel android:android:default`).
- On **startup** the core sends "♻️ Gateway online — …" to home channels of
*configured* platforms, but **does NOT send it to the android platform**
(no such log line ever appears). So the app must generate the "online" notice
itself.
### The gateway is a transient systemd user service
- Unit: `iris-hermes-gw.service` (transient, `/run/user/1000/systemd/transient/`).
- **It does NOT auto-revive on SIGTERM.** After a SIGTERM it goes to `failed`
and stays down until you run:
`systemctl --user restart iris-hermes-gw.service`
- Logs: `~/.hermes/logs/gateway.log`. Outbox DB: `~/.hermes/android/outbox.db`
(table `outbox(cursor, chat_id, frame, created)`).
## 3. What the previous attempt changed (all uncommitted, in working tree)
Run `git diff` to see the full patch. Summary:
### App (Kotlin)
- `app/shared/src/commonMain/kotlin/iris/util/TimeFormat.kt` +
`app/shared/src/jvmMain/kotlin/iris/util/TimeFormatJvm.kt`
— added `nowMillis(): Long` (expect/actual) for wall-clock timestamps.
- `app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt`
- `MessageItem` gained `isSystem: Boolean = false`.
- `addSystemMessage(lane, text)` — appends a centered local notice.
- `finalizeInterrupted()` — marks open `ToolItem`s `done=true, ok=false` and
streaming `MessageItem`s `streaming=false`, across all lanes.
- `app/shared/src/commonMain/kotlin/iris/state/IrisController.kt`
- In the `client.state.collect` block, tracks `prevState`:
- `Reconnecting → Connected`: posts `GATEWAY_ONLINE_MSG`
("♻️ Gateway online — Hermes is back and ready.") to the current lane.
- `Connected → Reconnecting`: calls `chat.finalizeInterrupted()`.
- New constant `GATEWAY_ONLINE_MSG`.
- `app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt`
- Renders `isSystem` messages via a new centered `SystemMessage` composable
(non-selectable pill).
### Gateway plugin (Python)
- `gateway-plugin/adapter.py`
- New `_is_gateway_lifecycle_notice(content)` — true when content contains
"Gateway restarting" / "Gateway shutting down" / "Gateway online".
- In `send()`, the tool-progress branch now skips lifecycle notices so they
fall through to **commentary** instead of a `tool.start` card.
## 4. Current (BROKEN) state — what the user is seeing
After several test restarts, the chat shows a **stack of repeated entries**:
multiple "⚠️ Gateway shutting down" cards (some with a **spinning** indicator,
some with a red ✗) interleaved with "♻️ Gateway online" pills. The **last**
entry is "Gateway shutting down" even though the app is **connected**. The user
is (correctly) unhappy: the ordering is wrong and a spinner is present.
### The two concrete defects
1. **Wrong ordering / stale "shutting down" last.** On reconnect the app posts
"♻️ Gateway online", but then the **`sync` catch-up replay** re-delivers the
"⚠️ Gateway shutting down" frame that the *old* gateway parked in the outbox
during shutdown. That replayed frame lands **after** the "online" notice, so
the newest line reads "shutting down" while connected.
2. **Spinner returns.** The outbox still contains `tool.start` frames for the
"Gateway shutting down" notice that were written **before** the adapter fix
was loaded. The `sync` replay turns those into tool cards that never receive
a `tool.end`, so they spin. The adapter fix only stops *new* `tool.start`
frames; it does not clean up the already-parked ones.
### Root cause (the part the previous attempt did NOT solve)
The app's **reconnect catch-up (`sync`)** replays the outbox from the stored
cursor, and the app does **not** deduplicate or order these replayed lifecycle
frames against what it already rendered live. Consequences:
- Replayed "shutting down" frames appear after the locally-generated "online"
notice (wrong order).
- Replayed `tool.start` frames (pre-fix) become endless-spinning cards.
- Every restart adds another pair of entries (the "chaos" stack).
The previous attempt treated the symptoms (finalize on disconnect, add an online
notice, reclassify new notices) but did **not** address the replay/dedup/ordering
problem, which is what actually produces the visible mess.
## 5. What is verified vs. not
- **Verified working:** Kotlin compiles (android + desktop), 53/53 Python
`tests/gateway/test_android.py` pass, the "♻️ Gateway online" pill renders,
and *new* lifecycle notices now render as commentary (not a tool card).
- **NOT working / still broken:** the chat accumulates a mis-ordered stack of
"shutting down"/"online" entries and a spinning tool card after restarts.
## 6. Suggested direction for the next engineer
Pick the cleanest of these (or a combination); the goal is: after a restart the
chat shows at most one "⚠️ Gateway restarting" then one "♻️ Gateway online", in
that order, with **no** spinner and **no** accumulation across restarts.
1. **Dedup/order lifecycle notices in the app.** Give the restart/online notices
stable identities and reconcile them (like `MessageItem` is by `messageId`):
a replayed "shutting down" that is already present should not be re-added,
and the "online" notice should supersede the "shutting down" one (replace,
not append). Consider a single "gateway state" line that updates in place
(restarting → online) instead of appending a new line each transition.
2. **Stop the spinner at the source.** Ensure no `tool.start` is ever emitted
for a lifecycle notice (adapter fix is done for new frames) AND make the app
resilient: a `tool.start` with no matching `tool.end` should not spin
indefinitely (e.g., finalize on the next turn, on reconnect, or time out).
3. **Cursor hygiene.** Investigate why the `sync` replay re-delivers frames the
app already rendered live (live broadcasts may not advance the stored
`syncCursor`). If live frames should advance the cursor, the replay would not
re-add them. Check `GatewayClient.dial` (sends `sync`), the `TYPE_SYNC_DONE`
handler in `IrisController`, and the gateway `on_sync`/outbox cursor logic.
4. **Clean the existing mess.** The outbox (`~/.hermes/android/outbox.db`) and
the app's in-memory lanes currently hold the stacked test entries. Decide
whether to prune the outbox and/or reset the app's chat state
(`adb shell pm clear dev.iris.app`) so the user starts clean.
## 7. Commands
- Build/verify app: `cd app && ./gradlew :shared:compileKotlinDesktop :androidApp:compileDebugKotlin`
- Install on phone: `cd app && ./gradlew :androidApp:installDebug` (device `a5ca2a4b`)
- Python tests: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`
- Restart gateway: `systemctl --user restart iris-hermes-gw.service`
- Screenshot: `adb exec-out screencap -p > /tmp/shot.png`
- Launch app: `adb shell am start -n dev.iris.app/.MainActivity`
- Reset app state: `adb shell pm clear dev.iris.app`
- Gateway log: `~/.hermes/logs/gateway.log`
## 8. Environment state at hand-off time
- Gateway: **running** (systemd `iris-hermes-gw.service` active).
- App: **installed** on the phone with the (broken) changes above.
- Working tree: 6 modified files (see `git diff`), **nothing committed**.
- The phone's chat currently shows the stacked test entries described in §4.
+16
View File
@@ -10,6 +10,10 @@ val composeVersion = "1.7.3"
val coroutinesVersion = "1.9.0"
val serializationVersion = "1.7.3"
val okhttpVersion = "4.12.0"
// 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 +31,23 @@ 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")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
androidMain.dependencies {
@@ -0,0 +1,216 @@
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,
) {
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 ->
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
}
},
codeBlock = { model ->
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
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.
*/
@Composable
private 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),
)
}
}
}
}
@@ -123,6 +123,7 @@ import iris.protocol.ROLE_USER
import iris.protocol.SearchHit
import iris.state.IrisController
import iris.state.ToolDetail
import iris.ui.MarkdownText
import iris.ui.rightClick
import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme
@@ -131,6 +132,8 @@ import iris.ui.theme.contrastText
import iris.util.formatDayLabel
import iris.util.formatTime
import iris.util.localDayKey
import iris.util.prepareForMarkdown
import iris.util.preserveNewlinesAsHardBreaks
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -1910,11 +1913,14 @@ private fun MessageBubble(
}
if (isUser) {
// M7: text + inline timestamp / delivery status at the end
// (reference look); the bubble hugs its content.
// (reference look); the bubble hugs its content. User text is
// rendered as markdown too (bold / italic / inline code), so a
// pasted snippet or emphasis shows up the same as in replies.
Row(verticalAlignment = Alignment.Bottom) {
if (msg.text.isNotBlank() || msg.streaming) {
Text(
msg.text + if (msg.streaming) " ▉" else "",
MarkdownText(
text = msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
if (msg.streaming) " ▉" else "",
color = textColor,
fontSize = 15.sp,
)
@@ -1945,10 +1951,16 @@ private fun MessageBubble(
}
} else {
if (msg.text.isNotBlank() || msg.streaming) {
Text(
msg.text + if (msg.streaming) " ▉" else "",
// M8: render the agent's reply as markdown (bold / italic /
// underscore, tables, highlighted code blocks). Leading
// newlines are stripped so the text hugs the top of the
// bubble; the ▉ cursor is kept while streaming.
val displayText = msg.text.prepareForMarkdown() + if (msg.streaming) " ▉" else ""
MarkdownText(
text = displayText,
color = textColor,
fontSize = if (isCommentary) 13.sp else 15.sp,
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -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")
}
@@ -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())
}
}