Skip to content

[Bug]: Android: inline file chips in assistant messages are drawn below their line and cover the next line's text #14425

Description

@jay-tau

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/mobile

Steps to reproduce

  1. Open a thread in the Android app.
  2. Get an assistant reply with a long, wrapping paragraph or list that mixes inline code spans, an external link that gets an icon (e.g. a GitHub link), and several file-path links that render as file chips.
  3. Look at the chips after the first line or two of that block.

Expected behavior

Each chip sits in the gap the text leaves for it, centred on its line.

Actual behavior

The text leaves a correctly sized gap for each chip, but the chip is drawn half a line or more below that gap and covers words on the next line. In the screenshot, bin covers "edit your", cargo covers "needs", and AGENTS.md / CLAUDE.md cover the line below them.

The first chip in the block, on the same line as the GitHub-icon link, is aligned. Later chips are offset by roughly 23 → 30 → 32 px (measured on the screenshot scaled to 900 px wide) and then stay at about that offset.

Two chips with the same label (bin) render differently: one aligned, one offset. renderContextChip returns the same cached bitmap, boxHeight and offsetY for identical payloads, so the difference must be in where the inline <View> is placed, not in the chip image.

Possible cause, from reading the source and not verified on a device: the inline-view positions from React Native's text measurement don't match the line layout the TextView actually draws, so chips drift away from their text. The comment above MarkdownSpannableFactory describes a similar measurement/render mismatch shifting inline images. Here the drift appears after lines that contain an inline link-icon <Image> or inline code, so those lines may measure taller than they render.

Relevant code:

  • Android chip inline box + absolutely positioned bitmap:
    accessibilityLabel={chip?.label}
    accessibilityRole={onPress ? "button" : "image"}
    accessibilityActions={onPress ? [{ name: "activate" }] : undefined}
    onAccessibilityAction={
    onPress
    ? (event) => {
    if (event.nativeEvent.actionName === "activate") onPress();
    }
    : undefined
    }
    style={{ width: androidChip.width, height: androidChip.boxHeight }}
    >
    <Image
    // The bitmap is measured in whole pixels but laid out in dp, so the box can
    // round a hair narrower than the image. `cover` would crop that difference
    // off the right-hand border; `contain` fits the whole chip instead.
    resizeMode="contain"
    source={{ uri: androidChip.uri }}
    style={{
    position: "absolute",
    left: 0,
    top: androidChip.offsetY,
    width: androidChip.width,
    height: androidChip.height,
    }}
    />
    </View>
    ) : Platform.OS === "android" && run.fileIcon ? (
  • renderContextChip (boxHeight / offsetY):
    Function("renderContextChip") { payloadJson: String ->
    val resources = appContext.reactContext?.resources ?: return@Function null
    val metrics = resources.displayMetrics
    val fontScale = resources.configuration.fontScale
    val key = "${metrics.density}:$fontScale:${metrics.widthPixels}:$payloadJson"
    chipImages.get(key)?.let { return@Function it }
    val payload = JSONObject(payloadJson)
    val chip = T3ContextChip(
    content = T3ContextChip.Content(
    label = payload.optString("label").take(4096),
    symbol = payload.optString("symbol", "doc")
    ),
    // The line box around the chip is measured with `fontScale` below, so the chip has to
    // carry it too, or it shrinks against the words beside it at a larger text size.
    fontSize =
    payload.optDouble("fontSize", 12.0).toFloat().coerceIn(10f, 40f) *
    metrics.density * fontScale,
    colors = T3ContextChip.Colors(
    accent = T3ContextChip.color(payload.optString("accent"), Color.GRAY),
    foreground = T3ContextChip.color(payload.optString("foreground"), Color.BLACK),
    border = T3ContextChip.color(payload.optString("border"), Color.GRAY)
    ),
    maximumWidth = (metrics.widthPixels - 80 * metrics.density).coerceAtLeast(100f),
    density = metrics.density,
    )
    // toInt() truncates, so a fractional pixel of the chip would fall outside the bitmap
    // and take the right-hand border with it. Round up: a spare column costs nothing.
    // One spare pixel of transparency on each side. Whatever rounding happens between the
    // bitmap's pixels and the box's dp then falls on padding instead of on the border.
    val bleed = 1
    val bitmap = Bitmap.createBitmap(
    ceil(chip.width).toInt() + bleed * 2,
    ceil(chip.height).toInt() + bleed * 2,
    Bitmap.Config.ARGB_8888
    )
    chip.draw(Canvas(bitmap), bleed.toFloat(), bleed.toFloat())
    val bytes = ByteArrayOutputStream()
    bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes)
    bitmap.recycle()
    // React Native sits an inline view's box on the text baseline and grows the line's
    // ascent to fit it, so a box as tall as the chip lifts the chip above the words and
    // pushes the baseline down. Report a box no taller than the paragraph font's ascent,
    // which leaves the line exactly as tall as a line of plain text, plus where inside
    // that box the bitmap must sit so the chip centres on the font's ascent/descent box:
    // the same rule the composer's ReplacementSpan uses to draw its chips.
    val lineMetrics = paragraphFontMetrics(
    payload.optJSONObject("text"),
    fontScale * metrics.density,
    )
    val ascent = -lineMetrics.ascent
    val descent = lineMetrics.descent
    val bitmapWidth = ceil(chip.width) + bleed * 2
    val bitmapHeight = ceil(chip.height) + bleed * 2
    val result = mapOf<String, Any>(
    "uri" to
    "data:image/png;base64,${Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP)}",
    // Layout rounds dp back to whole pixels. Reporting a hair less than the bitmap lets
    // that rounding land inside the image and crop its right-hand border, so round the
    // box up: an extra fraction of a pixel is invisible, a missing border is not.
    "width" to bitmapWidth / metrics.density,
    "height" to bitmapHeight / metrics.density,
    "boxHeight" to ascent / metrics.density,
    "offsetY" to (ascent + descent - bitmapHeight) / 2f / metrics.density,
    )
    chipImages.put(key, result)
    result
  • MarkdownSpannableFactory note:
    // Match React Native's measurement buffer. Android orders tied line-height
    // spans differently in SpannableString, shifting inline images once RN's
    // span priorities are exhausted.
    private object MarkdownSpannableFactory : Spannable.Factory() {
    override fun newSpannable(source: CharSequence): Spannable =
    SpannableStringBuilder(source)
    }

Impact

Minor bug or occasional failure

Version or commit

Android app 1.3.0 (com.t3tools.t3code)

Environment

Android phone, 1080×2400 display

Logs or stack traces

No response

Screenshots, recordings, or supporting files

Image

Workaround

No response

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions