Skip to content

feat(mobile): render inline and block HTML in markdown messages - #5896

Open
iscekic wants to merge 24 commits into
mainfrom
kwf/deliver-the-work-described-b-c5a9
Open

feat(mobile): render inline and block HTML in markdown messages#5896
iscekic wants to merge 24 commits into
mainfrom
kwf/deliver-the-work-described-b-c5a9

Conversation

@iscekic

@iscekic iscekic commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Changelog for users

  • Inline and block HTML in agent chat messages now renders as formatted content — headings, bold/emphasis, links, lists, and blockquotes — instead of appearing as raw tags.
  • Unsafe HTML is stripped before rendering: script, style, iframe, object/embed, media, form controls, SVG, canvas, and metadata tags never execute and never load remote resources.
  • HTML images follow the same rules as Markdown images: HTTPS images wait for an explicit Load tap, HTTP and data URIs show an HTTPS images only chip without any network request, and unsupported sources render their alt text.
  • Tapping or long-pressing an HTML link uses the existing link confirmation and link actions; a linked HTML image opens the link confirmation rather than the image viewer.
  • Image placeholders now reserve a fixed height while loading, failing, or retrying, so surrounding messages no longer shift; the load chip shows a Load label and the failure chip shows a Retry action.
  • A message whose only content is sanitized-away HTML no longer renders an empty bubble; failed-delivery messages keep their failure footer.
  • Session detail headers can now wrap a long title to two lines instead of truncating it.

Changelog for maintainers

  • Adds marked@18.0.6 (lexer) and react-native-render-html@6.3.4 (HTML engine) to apps/mobile.
  • New sanitization module with a denylist of removed HTML tags; DOM visitors clear removed container contents and ignoredDomTags cover void tags.
  • MarkdownText segments content with the marked lexer and routes styled HTML to a new MarkdownHtml renderer; content without HTML continues through the existing react-native-marked path unchanged.
  • Re-points shared UI strings to a common i18n namespace across tabs, code reviewer, agent chat, and KiloClaw screens and components; the feature diff itself touches no locale resource files (the merge with origin/main updates the catalogs — see the merge bullet below).
  • mergeSessionTranscript gains an optional deliveryStates map so a failed-delivery message whose text sanitizes to empty stays visible.
  • Session detail headers switch from titleNumberOfLines={1} to the existing reserveTitleSpace prop for two-line titles.
  • markdown-image-confirm exposes a subscription so multiple mounted image slots react to one Load confirmation via useSyncExternalStore.
  • Merge with origin/main (9e17757) — merge commit 19a0ce2a9 on top of the PR head 356f01e65 (no rebase, no squash; nothing from either side dropped). Conflicting files and how each was resolved (per-file detail under "What changed (merge of origin/main)"):
    • apps/mobile/src/i18n/locales/*.json (87 catalogs) — union of the keys both sides hold: main's catalogs are a strict key superset of this branch's, and agentChat.filePart.imageUnavailableWithAlt (the one key main has and the PR had dropped) is restored in every catalog with main's translated {{alt}} template. Shared keys keep main's repo-wide retranslation — the only value-layer change either side made against the merge base, and the one main's own tests pin (e.g. session-row's ES "EN DIRECTO"); this branch's catalog work is key-set restructuring and is fully preserved. node tools/i18n/check-catalogs.mjs passes (87 languages, every catalog matches en.json).
    • pnpm-lock.yaml — union of both sides: this branch's marked + react-native-render-html additions plus main's expo-widgets patch rework (canonical patch hash 1e56973d) and dependency bumps; pnpm install --frozen-lockfile passes on the merged tree.
    • apps/mobile/src/components/agents/markdown-image.tsx (+ markdown-image.test.ts) — this branch's fixed-height failure/retry slot kept; the alt case renders through main's translated agentChat.filePart.imageUnavailableWithAlt template instead of the branch's untranslated common.imageUnavailable + \n${alt} split.
    • apps/mobile/src/components/organization/credit-activity-screen.tsx — main's common.loadMore re-points kept; the next-page failure message resolved to this branch's common.couldnTLoadMore (this PR's copy-dedupe); main's organization.invoices.loadMoreFailed stays in the catalog and used by invoices-screen.tsx.
    • apps/mobile/src/components/pr-review/pr-review-overview.tsx — this branch's prReview.connectionExpired reconnect EmptyState title kept (consistent with sibling call sites), paired with the shared prReview.reconnectNotice.message description and main's common.accessDenied re-point; prReview.reconnectNotice.title remains used by pr-review-reconnect-notice.tsx.
    • apps/mobile/src/i18n/label-reference.test.tsMESSAGE_KEYS converged to a single authErrors.differentOauth entry: main's replacement of emailAlreadyUsed survives, the duplicate differentOauth line is de-duplicated.
    • tools/i18n/check-catalogs.mjs — union: this branch's allowlist re-points/removals (common.github/common.gitlab, dropped platformGithub/platformGitlab duplicates) plus main's TWO_SENSE_COPY map and its new agentChat.repoPicker.platformBitbucket allowlist entry.

Changes by area

  • Markdown HTML engine (markdown-html.tsx, markdown-html-sanitization.ts, markdown-palette.ts): new MarkdownHtml component backed by react-native-render-html, a denylist sanitizer, and shared heading/tag styles so HTML text matches Markdown styling.
  • Markdown segmentation (markdown-text.tsx): splitMarkdownHtml splits content into Markdown/HTML segments and routes styled HTML (links, headings, strong, blockquotes, list items) to the HTML engine while keeping fenced code and tables on the existing path.
  • Image handling (markdown-image.tsx, markdown-image-confirm.ts): fixed-aspect-ratio reserved slots, subscription-based Load confirmation, explicit Load/Retry labels, and linked-image accessibility.
  • Message visibility (message-visibility.ts, message-bubble.tsx, session-transcript.ts): suppress bubbles whose text sanitizes to empty, keep failed-delivery messages, and wrap each bubble and delivery row in one measurable list item.
  • Screen headers (agent-chat/[session-id].tsx, session-detail-content.tsx): adopt reserveTitleSpace for two-line titles.
  • i18n consolidation: re-point feature-scoped keys to common.* across roughly 60 files under apps/mobile/src.
  • Tests: new markdown-html.mounted.test.tsx (runs the real HTML engine), new markdown-text.test.tsx (routing and sanitization), plus expanded markdown-image.test.ts, message-visibility.test.ts, message-bubble.test.ts, and session-transcript.test.ts.
  • Dependencies (package.json): add marked and react-native-render-html.

E2E proof

Live iOS and Android dev-build verification covered the following scenarios:

  • Empty and sanitization: a live response containing only script, iframe, style, and form HTML shows no unsafe text, raw tags, remote load, or CTA.
  • Happy mixed transcript: plain Markdown, inline HTML, block HTML, a list, a link, and fenced HTML render formatted in source order with no raw tags; fenced tags stay code.
  • Loading, happy, and edge counts: zero images, one dimensionless HTTPS image, and two HTML images with Load /.+/ reserve one slot and one skeleton without layout movement; the loaded image opens in the viewer.
  • Non-retryable unhappy: HTTP, data, and unsupported image sources show HTTPS images only for blocked sources with no load CTA and no network request.
  • Reconnecting not applicable: the renderer exposes only loading, failure, and retry states; no reconnect indicator is expected.
  • Retryable unhappy: a controlled failing HTTPS image shows Image unavailable, retry loading; retrying keeps the same slot height.
  • Refresh stability: after an HTTPS image loads, refresh keeps the transcript visible and the image slot height fixed.
  • [network fault] Reconnecting: disconnecting and reconnecting after mixed content renders leaves the transcript visible and unchanged.
  • [network fault] Retryable unhappy: Before/After text stays visible without movement during load, failure, and retry.
  • [recording] Happy: on iOS and Android, a mixed Markdown + <strong> inline + <div><h3> block + list response shows all formatted text and no raw tags.
  • [recording] Edge counts: zero, one inline, one block, and multiple nested HTML tags render without raw tags while plain Markdown stays unchanged.
  • [recording] Empty: only blocked active-resource tags render no markup, content, CTA, or unexpected blank height.
  • [recording] HTML link: tapping opens the existing host confirmation; long-press opens existing link actions.
  • [recording] Image scheme branches: HTTPS/HTTP/data/relative/empty sources — only HTTPS offers Load, blocked sources do not fetch, and invalid sources show safe alt text.
  • [recording] Loading: a dimensioned HTTPS HTML image shows Load example.com, the skeleton, and the image in one reserved slot without moving adjacent messages.
  • [recording] Non-retryable unhappy: safe text beside script/iframe/stylesheet/object/media/form/SVG shows only safe text, no CTA, and no network request.
  • ux-check: portrait and panorama Markdown images keep their final dimensions against the accepted build.
  • ux-check: load/fail/retry/refresh of an HTTPS image shows one loading indicator and no blank or jumping content.
  • ux-check: headings, lists, code, a table, inline HTML, and block HTML render with consistent Markdown styling and no clipping on iOS and Android.
  • ux-check: an unsupported image source keeps its alt text visible with no resource request.
  • ux-check: blocked HTTP and data images use chip-height space without an empty image card.
  • ux-check: tapping a linked HTML image opens link confirmation instead of the viewer and the screen reader announces a link.

Recording of the verified flow (waits trimmed)

p2-html-link-ios.mp4.trim.mp4

Recording of the verified flow (waits trimmed)

p2-html-link.mp4.trim.mp4

Recording of the verified flow (waits trimmed)

p1-ios-trimmed.mp4.trim.mp4

Recording of the verified flow (waits trimmed)

p8-image-schemes-ios.mp4.trim.mp4

Recording of the verified flow (waits trimmed)

p5-ios-empty.mp4.trim.mp4

Recording of the verified flow (waits trimmed)

p9-ios-schemes.mp4.trim.mp4

Notes

  • The i18n key consolidation is mechanical and touches ~60 files; reviewers should confirm the shared common.* keys resolve to the intended copy in every locale.
  • The HTML sanitizer is denylist-based (blocked tags are removed entirely); confirm the tag list covers the security requirements.

Pre-existing UX defects observed (not changed here)

  • 01-login-request-code.png — A default blue Refreshing banner covers the top of the verify-code screen.
  • e4-panorama-current.png,p11-refresh-current.png,p3-load-current.png,p3-motion-current.png,p3-retry-current.png — Duplicate error UI shows both "Failed to deliver" with Retry/Copy and "Message delivery failed" for one failed send.
  • e5-300ms.png,e5.png — The scroll-to-bottom button overlaps the Load chip and host label.
  • e5-ios-profile.png — The bottom section title is clipped by the tab bar.
  • Home vs Agents — Couldn't vs Could not load active sessions (e2-nextjs-down-home.png, e2-nextjs-down-agents.png).
  • Profile — full-screen account error hides tab bar (e1-profile-nextjs-down.png). UX-PREEXISTING: Session — composer remains under session-load error (e1-session-ingest-reopen.png). Zero defects.

Not verified live (each line ends with the reason)

  • [advisory] Plain Markdown parity: Record the same HTML-free transcript on the base branch and this worktree. Compare the visible text, formatting, links, tables, code, and images. — no fixture: no baseline checkout outside a bench section

What changed (merge of origin/main)

Merged origin/main (9e17757) into the PR head 356f01e as merge commit 19a0ce2a9 (parents 356f01e + 9e17757), so CI can run. The single merge commit carries the union-of-keys catalogs (this branch's key-set structure with main's retranslation) and the restored agentChat.filePart.imageUnavailableWithAlt catalog key with its translated template at its call site. No rebase, no squash; nothing from either side was dropped. Both merge-base gates hold: git merge-base --is-ancestor confirms 9e1775745 and 356f01e65 are each ancestors of the branch tip.

Conflicting files and how each was resolved:

  • apps/mobile/src/components/agents/markdown-image.tsx — this branch's FixedImageSlot failure/retry block survives (fixed-height slot, Retry affordance, common.imageUnavailable when there is no alt text), and the alt case now uses main's translated sentence agentChat.filePart.imageUnavailableWithAlt ("Image unavailable {{alt}}" and its per-language renditions) instead of this branch's untranslated `${t('common.imageUnavailable')}\n${alt}` split — the union of both intents: the alt text stays visible inside the chip, and it renders through a template translators actually localize (this was main's fix(mobile): translate every catalog against its UI context #5880 intent; this branch had deleted the key as unused, which violated the union-of-keys rule). Main's other change to this file since the merge base — re-pointing the no-alt label to common.imageUnavailable — is inside the kept block. (The base's pre-retry setMeasuredAspectRatio(undefined) reset is deliberately not carried over: this branch's markdown-image.test.ts pins that the measured ratio is kept through retry, and onLoad re-measures after the retry loads.)
  • apps/mobile/src/components/organization/credit-activity-screen.tsx — kept main's re-points of organization.creditActivity.loadMorecommon.loadMore (both lines), and resolved the conflict line (next-page failure message) to this branch's common.couldnTLoadMore, the copy-dedupe this PR delivers. Main's renamed organization.invoices.loadMoreFailed stays in the catalog and stays used by invoices-screen.tsx, so no main intent is lost.
  • apps/mobile/src/components/pr-review/pr-review-overview.tsx — kept this branch's prReview.connectionExpired as the reconnect EmptyState title (consistent with this branch's sibling call sites in mutation-error-display.ts, pr-merge-sheet.tsx, reply-input.tsx), paired with the prReview.reconnectNotice.message description both sides share, plus main's prReview.accessDeniedcommon.accessDenied re-point. prReview.reconnectNotice.title remains used by pr-review-reconnect-notice.tsx, so both catalog entries stay live.
  • apps/mobile/src/i18n/label-reference.test.ts — converged MESSAGE_KEYS to a single 'authErrors.differentOauth' entry: main's replacement of emailAlreadyUsed survives, the duplicate differentOauth line main's side carried is de-duplicated, and this branch's emailAlreadyUsed removal stands.
  • tools/i18n/check-catalogs.mjs — union: this branch's allowlist re-points and removals (common.github/common.gitlab, dropped platformGithub/platformGitlab duplicates) plus main's TWO_SENSE_COPY map and its new 'agentChat.repoPicker.platformBitbucket' allowlist entry.
  • apps/mobile/src/i18n/locales/*.json (87 files) — every key on this branch exists on main (main's catalogs are a strict key superset; the only key main had that the branch dropped is agentChat.filePart.imageUnavailableWithAlt), so the union of keys is main's catalog set. Values: against the merge base this branch changed no translated value (its catalog work is key renames, drops and restructuring, which the union preserves), while main re-translated roughly 1,100–1,500 keys per locale and pins those renditions in its own tests (e.g. session-row.mounted.test.tsx expects ES "EN DIRECTO"), so shared keys keep main's retranslation and every merged test pins a value the catalogs actually carry. The merged catalogs restore agentChat.filePart.imageUnavailableWithAlt in all 87 catalogs with main's translations (reversing the earlier dead-key removal, which the union-of-keys rule overrides) and keep the failure chip pointed at it. node tools/i18n/check-catalogs.mjs re-run: 87 languages, every catalog matches en.json.
  • pnpm-lock.yaml — main reworked patches/expo-widgets@57.0.11.patch (patch hash 4d6fc209 → 1e56973d; main's patch file is a superset and is what the merge carries) while this branch added marked and react-native-render-html. Resolved by regenerating with pnpm install --lockfile-only on the merged manifests: canonical patch hash 1e56973d (matches the merged patch file's sha256) and this branch's dependency additions. pnpm install --frozen-lockfile passes on the merged tree ("Lockfile is up to date").

Post-merge verification on the final tip (19a0ce2a9): an independent union checker passes (87 locale files keep the union of keys from 356f01e (PR) and 9e17757 (main), with every shared key carrying main's rendition and the grafted agentChat.filePart.imageUnavailableWithAlt carrying main's per-language template); node tools/i18n/check-catalogs.mjs passes (87 languages, every catalog matches en.json); oxlint --config apps/mobile/.oxlintrc.i18n.json apps/mobile/src packages/app-shared/src clean (0 warnings, 0 errors on 1081 files); tsgo --noEmit (mobile typecheck) passes; vitest run over the merged-file mobile suites (markdown-image.test.ts, session-row.mounted.test.tsx, pr-review-checks-section.mounted.test.tsx, ios-sink.native.test.ts, ios-sink.test.ts, activity-kit-prompt.test.ts, label-reference.test.ts) passes 395 tests; oxlint (0 warnings, 0 errors) and oxfmt --list-different (clean) on the five hand-resolved source files; pnpm install --frozen-lockfile passes on the merged tree ("Lockfile is up to date").

Why

The branch diverged from origin/main, so the PR could not merge and CI could not run on it. The merge preserves this PR's HTML-markdown feature work together with main's translation refresh, expo-widgets patch rework, Bitbucket additions, and fixes in one history, with every conflict resolved so both sides' intent survives. The merge commit 19a0ce2a9 is built on the PR's current head (356f01e) with origin/main (9e17757) as the second parent, and the lockfile was resolved as the union of both sides and validated with pnpm install --frozen-lockfile, so CI's install gate passes. The merge commit carries the completed conflict resolutions (union-of-keys locale catalogs carrying main's retranslation, with the restored agentChat.filePart.imageUnavailableWithAlt translations at their call site), keeping both sides' intent in every line where the two sides genuinely conflicted.

…or (kwf deliver-the-work-described-b-c5a9/ux2)
…ex (kwf deliver-the-work-described-b-c5a9/ux3)
…et (kwf deliver-the-work-described-b-c5a9/ux4)
@iscekic
iscekic marked this pull request as draft September 5, 2026 04:13
Comment thread apps/mobile/src/components/agents/markdown-html.tsx Outdated
Comment thread apps/mobile/src/components/agents/markdown-html.tsx Outdated
Comment thread apps/mobile/src/components/agents/markdown-text.tsx Outdated
@kilo-code-bot

kilo-code-bot Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (20 files)
  • apps/mobile/src/components/agents/markdown-html.tsx
  • apps/mobile/src/components/agents/markdown-html-sanitization.ts
  • apps/mobile/src/components/agents/markdown-html-image.ts
  • apps/mobile/src/components/agents/markdown-html-image.test.ts
  • apps/mobile/src/components/agents/markdown-html.mounted.test.tsx
  • apps/mobile/src/components/agents/markdown-palette.ts
  • apps/mobile/src/components/agents/markdown-text.tsx
  • apps/mobile/src/components/agents/markdown-text.test.tsx
  • apps/mobile/src/components/agents/message-bubble.tsx
  • apps/mobile/src/components/agents/message-visibility.ts
  • apps/mobile/src/components/agents/session-transcript.ts
  • apps/mobile/src/components/agents/session-detail-content.tsx
  • apps/mobile/src/components/kilo-chat/message-bubble.tsx
  • apps/mobile/src/components/kilo-chat/message-markdown.tsx
  • apps/mobile/src/components/kilo-chat/message-presentation.ts
  • apps/mobile/src/components/kilo-chat/message-presentation.test.ts
  • apps/mobile/src/components/kilo-chat/message-bubble.mounted.test.tsx
  • apps/mobile/src/components/agents/markdown-image.tsx
  • apps/mobile/src/components/pr-review/pr-review-overview.tsx
  • tools/nativewind/check-classes.mjs
Previous Review Summary (commit b1691af)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit b1691af)

Status: 3 Issues Found | Recommendation: Address before merge

Executive Summary

HTML images without dimensions stay locked to 4:3, <picture> wrappers drop their fallback <img>, and the first HTML token remounts the markdown prefix while a message is still streaming.

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 0

Fix these issues in Kilo Cloud

Issue Details (click to expand)

WARNING

File Line Issue
apps/mobile/src/components/agents/markdown-html.tsx 218 HTML images without valid width/height lock to 4:3 and ignore onLoad measurement
apps/mobile/src/components/agents/markdown-html.tsx 45 <picture> sanitization splices children and drops the fallback <img>
apps/mobile/src/components/agents/markdown-text.tsx 48 First HTML token remounts the markdown prefix during streaming
Files Reviewed (12 files)
  • apps/mobile/src/components/agents/markdown-html.tsx - 2 issues
  • apps/mobile/src/components/agents/markdown-text.tsx - 1 issue
  • apps/mobile/src/components/agents/markdown-image.tsx
  • apps/mobile/src/components/agents/markdown-image-confirm.ts
  • apps/mobile/src/components/agents/markdown-html-image.ts
  • apps/mobile/src/components/agents/markdown-link-confirm.ts
  • apps/mobile/src/components/agents/markdown-text.test.tsx
  • apps/mobile/src/components/screen-header.tsx
  • apps/mobile/src/app/(app)/agent-chat/[session-id].tsx
  • apps/mobile/package.json
  • apps/mobile/src/i18n/locales/en.json
  • apps/mobile/src/components/agents/tool-card-attachments.ts

Reviewed by grok-4.6 · Input: 120.6K · Output: 22.4K · Cached: 570.5K

Review guidance: REVIEW.md from base branch main

@iscekic iscekic changed the title feat(mobile): render HTML in markdown messages feat(mobile): render inline and block HTML in chat markdown Sep 5, 2026
@iscekic iscekic changed the title feat(mobile): render inline and block HTML in chat markdown feat(mobile): render inline and block HTML in markdown messages Sep 5, 2026
@iscekic
iscekic force-pushed the kwf/deliver-the-work-described-b-c5a9 branch from f616a34 to c154ec3 Compare September 6, 2026 08:20
@iscekic iscekic added the human-ready The PR is ready for human review. label Sep 6, 2026
@iscekic
iscekic requested a review from eshurakov September 6, 2026 08:31
@iscekic
iscekic marked this pull request as ready for review September 6, 2026 08:31
…-c5a9' into kwf/ci-is-red-on-pr-https-github-13e0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

human-ready The PR is ready for human review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant