diff --git a/apps/ui/sources/components/markdown/enriched/useEnrichedMarkdownStyle.test.ts b/apps/ui/sources/components/markdown/enriched/useEnrichedMarkdownStyle.test.ts new file mode 100644 index 000000000..f4964d749 --- /dev/null +++ b/apps/ui/sources/components/markdown/enriched/useEnrichedMarkdownStyle.test.ts @@ -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>): 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); + }); +}); diff --git a/apps/ui/sources/components/sessions/pending/PendingMessagesTranscriptBlock.discardFallback.test.ts b/apps/ui/sources/components/sessions/pending/PendingMessagesTranscriptBlock.discardFallback.test.ts index 98b1745de..91e385faf 100644 --- a/apps/ui/sources/components/sessions/pending/PendingMessagesTranscriptBlock.discardFallback.test.ts +++ b/apps/ui/sources/components/sessions/pending/PendingMessagesTranscriptBlock.discardFallback.test.ts @@ -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', () => { diff --git a/apps/ui/sources/components/sessions/pending/PendingMessagesTranscriptBlock.test.tsx b/apps/ui/sources/components/sessions/pending/PendingMessagesTranscriptBlock.test.tsx index 4d78c2118..edf13b8a5 100644 --- a/apps/ui/sources/components/sessions/pending/PendingMessagesTranscriptBlock.test.tsx +++ b/apps/ui/sources/components/sessions/pending/PendingMessagesTranscriptBlock.test.tsx @@ -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', () => { diff --git a/apps/ui/sources/components/sessions/pending/PendingMessagesTranscriptBlock.tsx b/apps/ui/sources/components/sessions/pending/PendingMessagesTranscriptBlock.tsx index 963cb1faa..4128edf8c 100644 --- a/apps/ui/sources/components/sessions/pending/PendingMessagesTranscriptBlock.tsx +++ b/apps/ui/sources/components/sessions/pending/PendingMessagesTranscriptBlock.tsx @@ -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'; @@ -202,6 +202,7 @@ export function PendingMessagesTranscriptBlock(props: Readonly<{ onEditPendingMessage?: (request: PendingMessageEditRequest) => void | Promise; }>) { const { theme } = useUnistyles(); + const contentMaxWidth = useLayoutMaxWidth(); const session = useSession(props.sessionId); const pendingInputServerId = session?.serverId ?? resolvePreferredServerIdForSessionId(props.sessionId); const serverFeaturesSnapshot = useServerFeaturesSnapshotForServerId(pendingInputServerId ?? null, { @@ -1383,9 +1384,9 @@ export function PendingMessagesTranscriptBlock(props: Readonly<{ return ( - + - + ({ flexDirection: 'column', flexGrow: 1, flexBasis: 0, - maxWidth: layout.maxWidth, }, userMessageContainer: { maxWidth: '100%', diff --git a/apps/ui/sources/components/sessions/transcript/MessageView.tsx b/apps/ui/sources/components/sessions/transcript/MessageView.tsx index e9051add7..6ad3b2700 100644 --- a/apps/ui/sources/components/sessions/transcript/MessageView.tsx +++ b/apps/ui/sources/components/sessions/transcript/MessageView.tsx @@ -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'; @@ -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); @@ -333,7 +334,7 @@ export const MessageViewWithSessionCommon = React.memo(function MessageViewWithS ) === 'hidden') return null; return ( - + ({ flexDirection: 'column', flexGrow: 1, flexBasis: 0, - maxWidth: layout.maxWidth, }, recoveredHistoryIndicator: { marginHorizontal: 16, diff --git a/apps/ui/sources/components/sessions/transcript/toolCalls/ToolCallsGroupRow.tsx b/apps/ui/sources/components/sessions/transcript/toolCalls/ToolCallsGroupRow.tsx index 1c4ccd783..0cc406bdb 100644 --- a/apps/ui/sources/components/sessions/transcript/toolCalls/ToolCallsGroupRow.tsx +++ b/apps/ui/sources/components/sessions/transcript/toolCalls/ToolCallsGroupRow.tsx @@ -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'; @@ -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(); @@ -113,7 +114,7 @@ export const ToolCallsGroupRowWithSessionCommon = React.memo(function ToolCallsG - + ({ centeredContent: { flexGrow: 1, flexBasis: 0, - maxWidth: layout.maxWidth, }, })); diff --git a/apps/ui/sources/components/sessions/transcript/toolCalls/units/toolCallsGroupChrome.contentWidth.test.tsx b/apps/ui/sources/components/sessions/transcript/toolCalls/units/toolCallsGroupChrome.contentWidth.test.tsx new file mode 100644 index 000000000..1112e8273 --- /dev/null +++ b/apps/ui/sources/components/sessions/transcript/toolCalls/units/toolCallsGroupChrome.contentWidth.test.tsx @@ -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: (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>): 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 = () => ( + + {null} + + ); + 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); + }); +}); diff --git a/apps/ui/sources/components/sessions/transcript/toolCalls/units/toolCallsGroupChrome.tsx b/apps/ui/sources/components/sessions/transcript/toolCalls/units/toolCallsGroupChrome.tsx index 5e6b91b47..64368f221 100644 --- a/apps/ui/sources/components/sessions/transcript/toolCalls/units/toolCallsGroupChrome.tsx +++ b/apps/ui/sources/components/sessions/transcript/toolCalls/units/toolCallsGroupChrome.tsx @@ -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'; @@ -98,9 +98,10 @@ export function ToolCallsGroupUnitRowFrame(props: Readonly<{ unitTestID: string; children: React.ReactNode; }>) { + const contentMaxWidth = useLayoutMaxWidth(); return ( - + ({ centeredContent: { flexGrow: 1, flexBasis: 0, - maxWidth: layout.maxWidth, }, container: { marginHorizontal: 16, diff --git a/apps/ui/sources/components/ui/text/uiFontScale.test.ts b/apps/ui/sources/components/ui/text/uiFontScale.test.ts index 9e7c73700..704457bc6 100644 --- a/apps/ui/sources/components/ui/text/uiFontScale.test.ts +++ b/apps/ui/sources/components/ui/text/uiFontScale.test.ts @@ -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); diff --git a/apps/ui/sources/components/ui/text/uiFontScale.ts b/apps/ui/sources/components/ui/text/uiFontScale.ts index 9238eb772..a6702d908 100644 --- a/apps/ui/sources/components/ui/text/uiFontScale.ts +++ b/apps/ui/sources/components/ui/text/uiFontScale.ts @@ -16,6 +16,23 @@ function clonePreservingOwnProps(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'; @@ -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; } }