Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { TextStyle } from 'react-native';
import { describe, expect, it } from 'vitest';

import { buildEnrichedMarkdownStyle } from './useEnrichedMarkdownStyle';

const colors = {
text: { primary: '#111111', secondary: '#666666', link: '#0066cc' },
surface: { inset: '#eeeeee', elevated: '#ffffff', selected: '#dddddd' },
border: { default: '#cccccc' },
} as const;

type WebUnistylesTextStyle = TextStyle & Record<`unistyles_${string}`, unknown>;

// Mirrors the transcript's real web textStyle: a Unistyles-registered style whose numeric
// metrics are non-enumerable, non-writable (but configurable) data properties.
function createWebUnistylesTextStyle(values: Readonly<Record<'fontSize' | 'lineHeight', number>>): WebUnistylesTextStyle {
const style: WebUnistylesTextStyle = { unistyles_test: {} };
Object.defineProperties(
style,
Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {
value,
enumerable: false,
configurable: true,
}])),
);
return style;
}

describe('buildEnrichedMarkdownStyle uiFontScale', () => {
it('scales the markdown metrics from a plain transcript textStyle', () => {
const bundle = buildEnrichedMarkdownStyle({
colors,
profile: 'transcript',
uiFontScale: 1.3,
textStyle: { fontSize: 16, lineHeight: 24 },
});

expect(bundle.markdownStyle.paragraph?.fontSize).toBe(20.8);
expect(bundle.markdownStyle.paragraph?.lineHeight).toBe(31.2);
expect(bundle.markdownStyle.codeBlock?.fontSize).toBe(18.2);
});

it('scales the markdown metrics from a web Unistyles transcript textStyle', () => {
const bundle = buildEnrichedMarkdownStyle({
colors,
profile: 'transcript',
uiFontScale: 1.3,
textStyle: createWebUnistylesTextStyle({ fontSize: 16, lineHeight: 24 }),
});

expect(bundle.markdownStyle.paragraph?.fontSize).toBe(20.8);
expect(bundle.markdownStyle.paragraph?.lineHeight).toBe(31.2);
expect(bundle.markdownStyle.list?.fontSize).toBe(20.8);
expect(bundle.markdownStyle.h1?.fontSize).toBe(31.2);
});

it('keeps the unscaled metrics at scale 1', () => {
const bundle = buildEnrichedMarkdownStyle({
colors,
profile: 'transcript',
uiFontScale: 1,
textStyle: createWebUnistylesTextStyle({ fontSize: 16, lineHeight: 24 }),
});

expect(bundle.markdownStyle.paragraph?.fontSize).toBe(16);
expect(bundle.markdownStyle.paragraph?.lineHeight).toBe(24);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({

vi.mock('@/components/ui/layout/layout', () => ({
layout: { maxWidth: 800, headerMaxWidth: 800 },
useLayoutMaxWidth: () => 800,
}));

describe('PendingMessagesTranscriptBlock send cleanup failure', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({

vi.mock('@/components/ui/layout/layout', () => ({
layout: { maxWidth: 800, headerMaxWidth: 800 },
useLayoutMaxWidth: () => 800,
}));

describe('PendingMessagesTranscriptBlock', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useSession, useSetting } from '@/sync/domains/state/storage';
import { sync } from '@/sync/sync';
import { Modal } from '@/modal';
import { MarkdownView } from '@/components/markdown/MarkdownView';
import { layout } from '@/components/ui/layout/layout';
import { useLayoutMaxWidth } from '@/components/ui/layout/layout';
import { Text } from '@/components/ui/text/Text';
import { ActivitySpinner } from '@/components/ui/feedback/ActivitySpinner';
import { t } from '@/text';
Expand Down Expand Up @@ -202,6 +202,7 @@ export function PendingMessagesTranscriptBlock(props: Readonly<{
onEditPendingMessage?: (request: PendingMessageEditRequest) => void | Promise<void>;
}>) {
const { theme } = useUnistyles();
const contentMaxWidth = useLayoutMaxWidth();
const session = useSession(props.sessionId);
const pendingInputServerId = session?.serverId ?? resolvePreferredServerIdForSessionId(props.sessionId);
const serverFeaturesSnapshot = useServerFeaturesSnapshotForServerId(pendingInputServerId ?? null, {
Expand Down Expand Up @@ -1383,9 +1384,9 @@ export function PendingMessagesTranscriptBlock(props: Readonly<{

return (
<View testID="pendingMessages.block" style={styles.messageContainer} renderToHardwareTextureAndroid={true}>
<View style={styles.messageContent}>
<View style={[styles.messageContent, { maxWidth: contentMaxWidth }]}>
<View style={styles.userMessageContainer}>
<View style={{ width: '100%', maxWidth: layout.maxWidth }}>
<View style={{ width: '100%', maxWidth: contentMaxWidth }}>
<View style={styles.sectionHeader}>
<TranscriptSeparatorRow
iconName="clock"
Expand Down Expand Up @@ -1621,7 +1622,6 @@ const styles = StyleSheet.create(() => ({
flexDirection: 'column',
flexGrow: 1,
flexBasis: 0,
maxWidth: layout.maxWidth,
},
userMessageContainer: {
maxWidth: '100%',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { t } from '@/text';
import { Message, UserTextMessage, AgentTextMessage, ToolCallMessage } from "@/sync/domains/messages/messageTypes";
import { Metadata } from "@/sync/domains/state/storageTypes";
import type { OpenApprovalArtifactForSession } from '@/sync/domains/artifacts/approvalArtifacts';
import { layout } from "@/components/ui/layout/layout";
import { useLayoutMaxWidth } from "@/components/ui/layout/layout";
import { ToolView } from '@/components/tools/shell/views/ToolView';
import { ToolTimelineRow } from '@/components/tools/shell/views/ToolTimelineRow';
import { resolveToolStatusIndicatorKind } from '@/components/tools/shell/presentation/resolveToolStatusIndicatorKind';
Expand Down Expand Up @@ -313,6 +313,7 @@ export const MessageViewWithSessionCommon = React.memo(function MessageViewWithS
toolChromeCommon: TranscriptToolChromeCommon;
toolRouteCommon: TranscriptToolRouteCommon;
}) {
const contentMaxWidth = useLayoutMaxWidth();
const interaction = props.interaction ?? FAIL_CLOSED_TRANSCRIPT_INTERACTION;
const canFork = interaction.canFork === true;
const committedCanForkRef = React.useRef(canFork);
Expand All @@ -333,7 +334,7 @@ export const MessageViewWithSessionCommon = React.memo(function MessageViewWithS
) === 'hidden') return null;
return (
<View style={styles.messageContainer} renderToHardwareTextureAndroid={true}>
<View style={styles.messageContent}>
<View style={[styles.messageContent, { maxWidth: contentMaxWidth }]}>
<RecoveredHistoryIndicator message={props.message} />
<RenderBlock
message={props.message}
Expand Down Expand Up @@ -1773,7 +1774,6 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: 'column',
flexGrow: 1,
flexBasis: 0,
maxWidth: layout.maxWidth,
},
recoveredHistoryIndicator: {
marginHorizontal: 16,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
ToolCallsGroupViewWithSessionCommon,
} from '@/components/sessions/transcript/turns/toolCalls/ToolCallsGroupView';
import { TRANSCRIPT_WEB_TOOL_GROUP_PREPEND_ANCHOR_TEST_ID_PREFIX } from '@/components/sessions/transcript/viewport/prepend/webTranscriptPrependAnchor';
import { layout } from '@/components/ui/layout/layout';
import { useLayoutMaxWidth } from '@/components/ui/layout/layout';
import type { TranscriptInteraction } from '@/utils/sessions/deriveTranscriptInteraction';
import { resolveInactiveSessionToolCallFailure } from '@/components/tools/shell/permissions/resolveInactiveSessionToolCallFailure';
import { resolveToolStatusIndicatorKind } from '@/components/tools/shell/presentation/resolveToolStatusIndicatorKind';
Expand Down Expand Up @@ -54,6 +54,7 @@ export const ToolCallsGroupRow = React.memo(function ToolCallsGroupRow(props: To
export const ToolCallsGroupRowWithSessionCommon = React.memo(function ToolCallsGroupRowWithSessionCommon(
props: ToolCallsGroupRowProps & TranscriptSessionCommonProps,
) {
const contentMaxWidth = useLayoutMaxWidth();
const toolMessagesRaw = useMessagesByIds(props.sessionId, props.toolMessageIds);
const toolMessages = React.useMemo(() => {
const byId = new Map<string, ToolCallMessage>();
Expand Down Expand Up @@ -113,7 +114,7 @@ export const ToolCallsGroupRowWithSessionCommon = React.memo(function ToolCallsG
<View testID={`${TRANSCRIPT_WEB_TOOL_GROUP_PREPEND_ANCHOR_TEST_ID_PREFIX}${webPrependAnchorId}`}>
<TranscriptEnterWrapper id={props.toolCallsGroupId} createdAt={createdAt}>
<View style={styles.centered}>
<View style={styles.centeredContent}>
<View style={[styles.centeredContent, { maxWidth: contentMaxWidth }]}>
<ToolCallsGroupViewWithSessionCommon
id={props.toolCallsGroupId}
status={status}
Expand Down Expand Up @@ -148,6 +149,5 @@ const styles = StyleSheet.create(() => ({
centeredContent: {
flexGrow: 1,
flexBasis: 0,
maxWidth: layout.maxWidth,
},
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React, { act } from 'react';
import type { ReactTestInstance } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';

import { renderScreen } from '@/dev/testkit';
import { installToolCallsGroupViewCommonModuleMocks } from '@/components/sessions/transcript/turns/toolCalls/toolCallsGroupViewTestHelpers';
import { flattenStyleProp } from './toolCallsGroupUnitsTestFixtures';

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const shared = vi.hoisted(() => ({
contentWidthMode: 'compact' as 'compact' | 'medium' | 'full',
}));

installToolCallsGroupViewCommonModuleMocks({
reactNative: async () => {
const { createReactNativeWebMock } = await import('@/dev/testkit/mocks/reactNative');
return createReactNativeWebMock({
Platform: {
OS: 'web',
select: <T,>(values: Readonly<{ web?: T; default?: T }>) => values.web ?? values.default ?? null,
},
});
},
storage: async (importOriginal) => {
const { createStorageModuleMock } = await import('@/dev/testkit/mocks/storage');
return createStorageModuleMock({
importOriginal,
overrides: {
useLocalSetting: ((key: string) => {
if (key === 'uiContentWidthMode') return shared.contentWidthMode;
if (key === 'uiFontScale') return 1;
return undefined;
}) as typeof import('@/sync/domains/state/storage')['useLocalSetting'],
},
});
},
});

vi.mock('@/sync/domains/state/storageStore', () => ({
getStorage: () => ({
getState: () => ({
localSettings: {
uiContentWidthMode: shared.contentWidthMode,
},
}),
}),
}));

function findRowFrameMaxWidth(screen: Awaited<ReturnType<typeof renderScreen>>): unknown {
const matchingNode = screen.findAllByType('View' as never).find((node: ReactTestInstance) => {
const style = flattenStyleProp(node.props.style);
return style.flexGrow === 1 && style.flexBasis === 0 && style.maxWidth !== undefined;
});
return matchingNode ? flattenStyleProp(matchingNode.props.style).maxWidth : undefined;
}

describe('ToolCallsGroupUnitRowFrame content width', () => {
it('updates the row width cap when the local content width setting changes', async () => {
shared.contentWidthMode = 'compact';
const { ToolCallsGroupUnitRowFrame } = await import('./toolCallsGroupChrome');

const renderElement = () => (
<ToolCallsGroupUnitRowFrame variant="cards" position="middle" unitTestID="unit.row">
{null}
</ToolCallsGroupUnitRowFrame>
);
const screen = await renderScreen(renderElement());

expect(findRowFrameMaxWidth(screen)).toBe(850);

shared.contentWidthMode = 'full';
await act(async () => {
screen.tree.update(renderElement());
});

expect(findRowFrameMaxWidth(screen)).toBe(Number.POSITIVE_INFINITY);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ActivitySpinner, iconMatchedSpinnerSize } from '@/components/ui/feedbac
import { Text } from '@/components/ui/text/Text';
import { t } from '@/text';
import { Typography } from '@/constants/Typography';
import { layout } from '@/components/ui/layout/layout';
import { useLayoutMaxWidth } from '@/components/ui/layout/layout';
import { resolveInactiveSessionToolCallFailure } from '@/components/tools/shell/permissions/resolveInactiveSessionToolCallFailure';
import { resolveToolStatusIndicatorKind } from '@/components/tools/shell/presentation/resolveToolStatusIndicatorKind';

Expand Down Expand Up @@ -98,9 +98,10 @@ export function ToolCallsGroupUnitRowFrame(props: Readonly<{
unitTestID: string;
children: React.ReactNode;
}>) {
const contentMaxWidth = useLayoutMaxWidth();
return (
<View style={unitStyles.centered}>
<View style={unitStyles.centeredContent}>
<View style={[unitStyles.centeredContent, { maxWidth: contentMaxWidth }]}>
<View
testID={props.unitTestID}
style={resolveToolCallsGroupUnitContainerStyle(props.variant, props.position)}
Expand Down Expand Up @@ -270,7 +271,6 @@ const unitStyles = StyleSheet.create((theme) => ({
centeredContent: {
flexGrow: 1,
flexBasis: 0,
maxWidth: layout.maxWidth,
},
container: {
marginHorizontal: 16,
Expand Down
29 changes: 29 additions & 0 deletions apps/ui/sources/components/ui/text/uiFontScale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,35 @@ describe('uiFontScale', () => {
expect(scaled[marker]).toEqual({ className: 'unistyles_x' });
});

it('scales web Unistyles styles whose metrics are non-enumerable, non-writable properties', () => {
// Mirrors react-native-unistyles/src/web removeInlineStyles + assignSecrets: style values
// become non-enumerable, non-writable (but configurable) data properties, and the secret
// lives under an enumerable `unistyles_*` key without the native `uni__getStyles` shape.
const style: any = {};
Object.defineProperties(style, {
fontSize: { value: 16, enumerable: false, configurable: true },
lineHeight: { value: 24, enumerable: false, configurable: true },
});
style.unistyles_web1 = {};
Object.defineProperty(style.unistyles_web1, '__uni__key', {
value: 'transcriptMarkdownText',
enumerable: false,
configurable: true,
});

const scaled = scaleTextStyle(style, 1.3) as any;

expect(scaled).not.toBe(style);
expect(scaled.fontSize).toBe(20.8);
expect(scaled.lineHeight).toBe(31.2);
// Enumerability is preserved so the web renderer keeps sizing text via CSS classes.
expect(Object.getOwnPropertyDescriptor(scaled, 'fontSize')?.enumerable).toBe(false);
expect(scaled.unistyles_web1).toBe(style.unistyles_web1);
// The original registered style must never be mutated.
expect(style.fontSize).toBe(16);
expect(style.lineHeight).toBe(24);
});

it('does not crash on nullish styles', () => {
expect(scaleTextStyle(null, 1.1)).toBe(null);
expect(scaleTextStyle(undefined, 1.1)).toBe(undefined);
Expand Down
26 changes: 22 additions & 4 deletions apps/ui/sources/components/ui/text/uiFontScale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ function clonePreservingOwnProps<T extends object>(entry: T): T {
}
}

function setScaledMetric(target: any, key: string, value: number): void {
const descriptor = Object.getOwnPropertyDescriptor(target, key);
if (!descriptor || (descriptor.writable === true && !descriptor.get && !descriptor.set)) {
target[key] = value;
return;
}
// Web Unistyles registers style values as non-enumerable, non-writable data properties
// (see react-native-unistyles/src/web removeInlineStyles). Redefine the metric on the
// clone while preserving its enumerability so CSS-class-driven rendering is unaffected.
Object.defineProperty(target, key, {
value,
enumerable: descriptor.enumerable,
configurable: true,
writable: true,
});
}

function scaleNumericTextMetrics(entry: any, uiFontScale: number): any {
const hasFontSize = typeof entry?.fontSize === 'number';
const hasLineHeight = typeof entry?.lineHeight === 'number';
Expand All @@ -24,12 +41,13 @@ function scaleNumericTextMetrics(entry: any, uiFontScale: number): any {

const next: any = clonePreservingOwnProps(entry as any);
try {
if (hasFontSize) next.fontSize = roundTo2(next.fontSize * uiFontScale);
if (hasLineHeight) next.lineHeight = roundTo2(next.lineHeight * uiFontScale);
if (hasLetterSpacing) next.letterSpacing = roundTo2(next.letterSpacing * uiFontScale);
if (hasFontSize) setScaledMetric(next, 'fontSize', roundTo2(entry.fontSize * uiFontScale));
if (hasLineHeight) setScaledMetric(next, 'lineHeight', roundTo2(entry.lineHeight * uiFontScale));
if (hasLetterSpacing) setScaledMetric(next, 'letterSpacing', roundTo2(entry.letterSpacing * uiFontScale));
return next;
} catch {
// If the style object is non-writable (or uses accessors), avoid corrupting opaque metadata.
// A non-configurable, non-writable metric cannot be scaled without corrupting opaque
// metadata; fail closed to the unscaled style.
return entry;
}
}
Expand Down
Loading