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:
1 parent
a8920d8d26
commit
f8effb4152
6 files changed
+330
-177
No files matched your search
@@ -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.
|
|
||||||
@@ -10,6 +10,10 @@ val composeVersion = "1.7.3"
|
|||||||
val coroutinesVersion = "1.9.0"
|
val coroutinesVersion = "1.9.0"
|
||||||
val serializationVersion = "1.7.3"
|
val serializationVersion = "1.7.3"
|
||||||
val okhttpVersion = "4.12.0"
|
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 {
|
kotlin {
|
||||||
androidTarget()
|
androidTarget()
|
||||||
@@ -27,11 +31,23 @@ kotlin {
|
|||||||
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
|
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
|
||||||
implementation("org.jetbrains.compose.material3:material3:$composeVersion")
|
implementation("org.jetbrains.compose.material3:material3:$composeVersion")
|
||||||
implementation("org.jetbrains.compose.ui:ui:$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-coroutines-core:$coroutinesVersion")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
|
||||||
// OkHttp is a JVM library; both targets here are JVM-based
|
// OkHttp is a JVM library; both targets here are JVM-based
|
||||||
// (androidTarget + jvm("desktop")), so it resolves on both.
|
// (androidTarget + jvm("desktop")), so it resolves on both.
|
||||||
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
|
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).
|
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
|
||||||
androidMain.dependencies {
|
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.protocol.SearchHit
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
import iris.state.ToolDetail
|
import iris.state.ToolDetail
|
||||||
|
import iris.ui.MarkdownText
|
||||||
import iris.ui.rightClick
|
import iris.ui.rightClick
|
||||||
import iris.ui.theme.IrisColors
|
import iris.ui.theme.IrisColors
|
||||||
import iris.ui.theme.LocalUserTheme
|
import iris.ui.theme.LocalUserTheme
|
||||||
@@ -131,6 +132,8 @@ import iris.ui.theme.contrastText
|
|||||||
import iris.util.formatDayLabel
|
import iris.util.formatDayLabel
|
||||||
import iris.util.formatTime
|
import iris.util.formatTime
|
||||||
import iris.util.localDayKey
|
import iris.util.localDayKey
|
||||||
|
import iris.util.prepareForMarkdown
|
||||||
|
import iris.util.preserveNewlinesAsHardBreaks
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -1910,11 +1913,14 @@ private fun MessageBubble(
|
|||||||
}
|
}
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
// M7: text + inline timestamp / delivery status at the end
|
// 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) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
if (msg.text.isNotBlank() || msg.streaming) {
|
if (msg.text.isNotBlank() || msg.streaming) {
|
||||||
Text(
|
MarkdownText(
|
||||||
msg.text + if (msg.streaming) " ▉" else "",
|
text = msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
|
||||||
|
if (msg.streaming) " ▉" else "",
|
||||||
color = textColor,
|
color = textColor,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
)
|
)
|
||||||
@@ -1945,10 +1951,16 @@ private fun MessageBubble(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (msg.text.isNotBlank() || msg.streaming) {
|
if (msg.text.isNotBlank() || msg.streaming) {
|
||||||
Text(
|
// M8: render the agent's reply as markdown (bold / italic /
|
||||||
msg.text + if (msg.streaming) " ▉" else "",
|
// 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,
|
color = textColor,
|
||||||
fontSize = if (isCommentary) 13.sp else 15.sp,
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user