Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
84d8c5b
Render safe HTML in mobile Markdown (kwf deliver-the-work-described-b…
iscekic Sep 4, 2026
7eb83f4
Keep Markdown image slots stable (kwf deliver-the-work-described-b-c5…
iscekic Sep 4, 2026
8134256
UX: Mixed messages lose the established table, code, and Markdow (kwf…
iscekic Sep 4, 2026
405257f
chore: UX: The user cannot open a linked image destination with the n…
iscekic Sep 4, 2026
1642952
chore: UX: The image and its description disappear. The user gets no …
iscekic Sep 4, 2026
bbdb2b0
chore: UX: Plain Markdown portrait and panorama images become small l…
iscekic Sep 4, 2026
2a3a4b5
fix: fix the failed mobile-app verification gate (kwf deliver-the-wor…
iscekic Sep 4, 2026
3dee9c5
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 4, 2026
40c8ac4
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 4, 2026
5b34b2c
fix: fix visual defects from the spot check (kwf deliver-the-work-des…
iscekic Sep 4, 2026
72f7996
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 4, 2026
7b36d8d
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 4, 2026
21c65d1
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 4, 2026
dae09d6
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 4, 2026
143688b
fix(i18n): drop unused catalog keys (agentChat.filePart.imageUnavaila…
iscekic Sep 4, 2026
b1691af
chore: reuse the existing catalog key for duplicated copy (kwf delive…
iscekic Sep 5, 2026
d111b85
fix: fix visual defects from the spot check (kwf deliver-the-work-des…
iscekic Sep 5, 2026
838a827
fix: fix visual defects from the spot check (kwf deliver-the-work-des…
iscekic Sep 5, 2026
356f01e
fix: address-the-automated-review-a8eb patch delivery
iscekic Sep 5, 2026
899f62e
merge: origin/main into kwf/deliver-the-work-described-b-c5a9
iscekic Sep 6, 2026
eaa3a7e
fix: repair the failing unit tests (kwf pr-https-github-com-kilo-org-…
iscekic Sep 6, 2026
f616a34
fix: ci-is-red-on-pr-https-github-13e0 patch delivery
iscekic Sep 6, 2026
c154ec3
feat(mobile): render inline and block HTML in markdown messages
iscekic Sep 6, 2026
f47593f
Merge remote-tracking branch 'origin/kwf/deliver-the-work-described-b…
iscekic Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"jotai": "2.20.2",
"lowlight": "3.3.0",
"lucide-react-native": "1.33.0",
"marked": "18.0.6",
"nativewind": "5.0.0-preview.4",
"posthog-react-native": "4.63.5",
"react": "19.2.3",
Expand All @@ -113,6 +114,7 @@
"react-native-gesture-handler": "2.32.0",
"react-native-marked": "8.1.1",
"react-native-reanimated": "4.5.1",
"react-native-render-html": "6.3.4",
"react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.26.2",
"react-native-svg": "15.15.4",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -992,8 +992,9 @@ describe.each([true, false])('SessionDetailScreen header return with history=%s'
);
const header = renderer.root.findByType(ScreenHeader);
const title = header.findByProps({ accessibilityRole: 'header' });
expect(propOf(title, 'numberOfLines')).toBe(1);
expect(propOf(title, 'numberOfLines')).toBe(2);
expect(propOf(title, 'ellipsizeMode')).toBe('tail');
expect(title.parent?.props.className).toContain('min-h-14');
const back = findByType(header, 'Pressable').find(
node => propOf(node, 'accessibilityLabel') === 'Go back'
);
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/app/(app)/agent-chat/[session-id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export default function SessionDetailScreen() {
<View className="flex-1 bg-background">
<ScreenHeader
title={t('agentChat.session.title')}
titleNumberOfLines={1}
reserveTitleSpace
backFallback="/(app)/(tabs)/(2_agents)"
headerRight={
<SessionContextMetrics
Expand Down Expand Up @@ -192,7 +192,7 @@ export default function SessionDetailScreen() {
<View className="flex-1 bg-background">
<ScreenHeader
title={t('agentChat.session.title')}
titleNumberOfLines={1}
reserveTitleSpace
backFallback="/(app)/(tabs)/(2_agents)"
/>
<SessionConnectionIndicator />
Expand Down
29 changes: 28 additions & 1 deletion apps/mobile/src/components/agents/markdown-html-image.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import { describe, expect, it } from 'vitest';

import { parseHtmlImages, stripToFixedPoint } from './markdown-html-image';
import {
IMAGE_PREVIEW_MAX_ASPECT_RATIO,
IMAGE_PREVIEW_MIN_ASPECT_RATIO,
} from './tool-card-attachments';

import {
parseHtmlImages,
resolveHtmlImageAspectRatio,
stripToFixedPoint,
} from './markdown-html-image';

describe('resolveHtmlImageAspectRatio', () => {
it('returns the clamped preview ratio when both attributes parse as positive finite numbers', () => {
expect(resolveHtmlImageAspectRatio('1600', '900')).toBeCloseTo(1600 / 900);
expect(resolveHtmlImageAspectRatio('400', '2000')).toBe(IMAGE_PREVIEW_MIN_ASPECT_RATIO);
expect(resolveHtmlImageAspectRatio('4000', '500')).toBe(IMAGE_PREVIEW_MAX_ASPECT_RATIO);
});

it('returns undefined when an attribute is missing, unparsable, or not positive', () => {
expect(resolveHtmlImageAspectRatio(undefined, '900')).toBeUndefined();
expect(resolveHtmlImageAspectRatio('1600', undefined)).toBeUndefined();
expect(resolveHtmlImageAspectRatio('400px', '900')).toBeUndefined();
expect(resolveHtmlImageAspectRatio('1600', 'auto')).toBeUndefined();
expect(resolveHtmlImageAspectRatio('', '900')).toBeUndefined();
expect(resolveHtmlImageAspectRatio('0', '900')).toBeUndefined();
expect(resolveHtmlImageAspectRatio('1600', '-1')).toBeUndefined();
});
});

describe('parseHtmlImages parser', () => {
it('parses double-quoted attributes', () => {
Expand Down
40 changes: 28 additions & 12 deletions apps/mobile/src/components/agents/markdown-html-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,26 @@ export function stripToFixedPoint(value: string, re: RegExp): string {
}
}

/**
* width/height attributes → clamped preview aspect ratio, but only when both
* parse as positive finite numbers; otherwise `undefined` so the renderer can
* adopt the intrinsic ratio measured on load.
*/
export function resolveHtmlImageAspectRatio(
width: string | undefined,
height: string | undefined
): number | undefined {
if (width === undefined || height === undefined) {
return undefined;
}
const w = Number(width);
const h = Number(height);
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
return undefined;
}
return resolveImagePreviewAspectRatio(w, h);
}

function imgTagToImage(tag: string): HtmlImage | null {
const srcRaw = attrValue(tag, ATTR_SRC);
if (srcRaw === undefined) {
Expand All @@ -76,18 +96,14 @@ function imgTagToImage(tag: string): HtmlImage | null {
const altRaw = attrValue(tag, ATTR_ALT);
const alt = altRaw !== undefined ? decodeEntities(altRaw) : '';

let aspectRatio: number | undefined = undefined;
const widthRaw = attrValue(tag, ATTR_WIDTH);
const heightRaw = attrValue(tag, ATTR_HEIGHT);
if (widthRaw !== undefined && heightRaw !== undefined) {
const w = Number(widthRaw);
const h = Number(heightRaw);
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
aspectRatio = resolveImagePreviewAspectRatio(w, h);
}
}

return { src, alt, aspectRatio };
return {
src,
alt,
aspectRatio: resolveHtmlImageAspectRatio(
attrValue(tag, ATTR_WIDTH),
attrValue(tag, ATTR_HEIGHT)
),
};
}

/**
Expand Down
66 changes: 66 additions & 0 deletions apps/mobile/src/components/agents/markdown-html-sanitization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export const REMOVED_HTML_TAGS = [
'script',
'style',
'link',
'iframe',
'frame',
'frameset',
'object',
'embed',
'applet',
'audio',
'video',
'source',
'track',
'form',
'input',
'button',
'select',
'option',
'optgroup',
'textarea',
'label',
'fieldset',
'legend',
'datalist',
'output',
'meter',
'progress',
'svg',
'canvas',
'base',
'head',
'meta',
'title',
'template',
'noscript',
] as const;

const REMOVED_TAG_NAMES = REMOVED_HTML_TAGS.join('|');
const REMOVED_CONTAINER_RE = new RegExp(
`<(${REMOVED_TAG_NAMES})\\b[^>]*>[\\s\\S]*?<\\/\\1\\s*>`,
'gi'
);
const REMOVED_TAG_RE = new RegExp(`<\\/?(?:${REMOVED_TAG_NAMES})\\b[^>]*>`, 'gi');
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
// A container emptied by the removals above draws no ink either: the HTML
// engine renders `<div></div>` as an empty box. Stripping emptied containers
// (iterated to a fixpoint so nesting collapses outermost-last) keeps the
// predicate aligned with what the renderer actually paints.
const EMPTY_CONTAINER_RE = /<([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>\s*<\/\1\s*>/g;

/** True when the HTML renderer removes every non-whitespace character. */
export function htmlSanitizesToEmpty(value: string): boolean {
let sanitized = value;
for (;;) {
const next = sanitized
.replace(HTML_COMMENT_RE, '')
.replace(REMOVED_CONTAINER_RE, '')
.replace(REMOVED_TAG_RE, '')
.replace(EMPTY_CONTAINER_RE, '');
if (next === sanitized) {
return next.trim() === '';
}
sanitized = next;
}
}
Loading