diff --git a/packages/app/e2e/browser/content-width.spec.ts b/packages/app/e2e/browser/content-width.spec.ts new file mode 100644 index 0000000000..bd3cc4394f --- /dev/null +++ b/packages/app/e2e/browser/content-width.spec.ts @@ -0,0 +1,70 @@ +import { test } from "../support/fixtures"; +import { + expectContentColumnWidth, + expectCustomContentWidthSetting, + expectDefaultContentWidthSetting, + openAppearanceSettings, + resetContentWidth, + setContentWidth, +} from "../support/helpers/content-width"; +import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +const ULTRAWIDE_VIEWPORT = { width: 2560, height: 1080 }; + +test("widens chat and Markdown files to the chosen content width and resets to the default", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "content-width-", + title: "Content width", + initialPrompt: "Write a long paragraph.", + }); + + try { + await agent.client.waitForFinish(agent.agentId, 30_000); + await page.setViewportSize(ULTRAWIDE_VIEWPORT); + const assistantMessage = page.getByTestId("assistant-message").first(); + const markdownPreview = page.getByTestId("markdown-preview-frame").first(); + + await test.step("chat starts at the default width", async () => { + await openAgentRoute(page, agent); + await expectContentColumnWidth(assistantMessage, { max: 820 }); + }); + + await test.step("settings show the default without a reset", async () => { + await openAppearanceSettings(page); + await expectDefaultContentWidthSetting(page, 820); + }); + + await test.step("a custom width is saved and offers a reset", async () => { + await setContentWidth(page, 1600); + await expectCustomContentWidthSetting(page, 1600); + await page.screenshot({ path: testInfo.outputPath("settings-custom-width.png") }); + }); + + await test.step("chat and Markdown files use the custom width", async () => { + await openAgentRoute(page, agent); + await expectContentColumnWidth(assistantMessage, { min: 1200, max: 1600 }); + await page.screenshot({ path: testInfo.outputPath("chat-custom-width.png") }); + + await openFileExplorer(page); + await openFileFromExplorer(page, "README.md"); + await expectContentColumnWidth(markdownPreview, { min: 1200, max: 1600 }); + await page.screenshot({ path: testInfo.outputPath("markdown-custom-width.png") }); + }); + + await test.step("reset returns to the default without storing it", async () => { + await openAppearanceSettings(page); + await resetContentWidth(page); + await expectDefaultContentWidthSetting(page, 820); + + await openAgentRoute(page, agent); + await expectContentColumnWidth(assistantMessage, { max: 820 }); + await page.screenshot({ path: testInfo.outputPath("chat-default-width.png") }); + }); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/support/helpers/content-width.ts b/packages/app/e2e/support/helpers/content-width.ts new file mode 100644 index 0000000000..a99ee6a956 --- /dev/null +++ b/packages/app/e2e/support/helpers/content-width.ts @@ -0,0 +1,63 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { openSettings } from "./app"; +import { openSettingsSection } from "./settings"; + +const APP_SETTINGS_KEY = "@paseo:app-settings"; + +function contentWidthInput(page: Page): Locator { + return page.getByLabel("Content width in pixels", { exact: true }); +} + +function resetContentWidthButton(page: Page): Locator { + return page.getByRole("button", { name: "Reset content width to default", exact: true }); +} + +export async function openAppearanceSettings(page: Page): Promise { + await openSettings(page); + await openSettingsSection(page, "appearance"); +} + +export async function setContentWidth(page: Page, width: number): Promise { + const input = contentWidthInput(page); + await input.fill(String(width)); + await input.press("Tab"); +} + +export async function resetContentWidth(page: Page): Promise { + await resetContentWidthButton(page).click(); +} + +export async function expectDefaultContentWidthSetting(page: Page, width: number): Promise { + await expect(contentWidthInput(page)).toHaveValue(String(width)); + await expect(resetContentWidthButton(page)).toHaveCount(0); + await expect.poll(() => readStoredContentWidth(page)).toBeNull(); +} + +export async function expectCustomContentWidthSetting(page: Page, width: number): Promise { + await expect(contentWidthInput(page)).toHaveValue(String(width)); + await expect(resetContentWidthButton(page)).toBeVisible(); + await expect.poll(() => readStoredContentWidth(page)).toBe(width); +} + +/** Asserts the rendered width of a content-column element stays within `max` and above `min`. */ +export async function expectContentColumnWidth( + element: Locator, + bounds: { min?: number; max: number }, +): Promise { + await expect(element).toBeVisible({ timeout: 30_000 }); + await expect + .poll(async () => (await element.boundingBox())?.width ?? 0) + .toBeLessThanOrEqual(bounds.max); + await expect + .poll(async () => (await element.boundingBox())?.width ?? 0) + .toBeGreaterThan(bounds.min ?? 0); +} + +async function readStoredContentWidth(page: Page): Promise { + return page.evaluate((key) => { + const raw = localStorage.getItem(key); + return raw === null + ? undefined + : (JSON.parse(raw) as { contentMaxWidth?: unknown }).contentMaxWidth; + }, APP_SETTINGS_KEY); +} diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 1d86943e05..c0fe52e3fc 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -1,6 +1,7 @@ /** * @vitest-environment jsdom */ +import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme"; import React from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -144,6 +145,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, })} , ); @@ -187,6 +189,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }), ); }); @@ -233,6 +236,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }; container = document.createElement("div"); document.body.appendChild(container); @@ -291,6 +295,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }; const unrevised = { contentById: new Set(), @@ -354,6 +359,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }; container = document.createElement("div"); document.body.appendChild(container); @@ -429,6 +435,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }), ); }); @@ -473,6 +480,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }), ); }); @@ -553,6 +561,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }; container = document.createElement("div"); document.body.appendChild(container); @@ -646,6 +655,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }; container = document.createElement("div"); document.body.appendChild(container); @@ -755,6 +765,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }; container = document.createElement("div"); document.body.appendChild(container); @@ -860,6 +871,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }; container = document.createElement("div"); document.body.appendChild(container); @@ -959,6 +971,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }; container = document.createElement("div"); document.body.appendChild(container); @@ -1056,6 +1069,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }; container = document.createElement("div"); document.body.appendChild(container); @@ -1175,6 +1189,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }; container = document.createElement("div"); document.body.appendChild(container); @@ -1408,6 +1423,7 @@ describe("createWebStreamStrategy", () => { listStyle: null, baseListContentContainerStyle: null, forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, }; const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => ( {strategy.render(input)} diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 4b0d73d681..c798c374d4 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -303,6 +303,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool hasOlderHistory, olderHistoryProgressKey, scrollEnabled, + contentMaxWidth, isMobileBreakpoint, } = props; const historyVirtualized = useRevisedHistoryRows( @@ -403,7 +404,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index, estimateSize: (index: number) => { const row = segments.historyVirtualized[index]; - return row ? estimateStreamItemHeight(row) : 120; + return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120; }, measureElement: measureVirtualElement, rangeExtractor, diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index ce56547a5e..5f43f8f6b1 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -92,6 +92,8 @@ export interface StreamRenderInput { listStyle: StyleProp; baseListContentContainerStyle: StyleProp; forwardListContentContainerStyle: StyleProp; + // The chat column's max width. The web viewport estimates unmeasured row heights with it. + contentMaxWidth: number; } export interface ResolveStreamRenderStrategyInput { diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index d1ddd40697..acbd920b81 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -1,7 +1,6 @@ import React, { memo, useCallback, useMemo, type ReactNode } from "react"; import { View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { SPACING, type Theme } from "@/styles/theme"; import type { TurnTiming } from "@/timeline/turn-time"; import type { StreamItem } from "@/types/stream"; @@ -215,7 +214,7 @@ function TurnFooterRow({ children }: { children: ReactNode }) { const stylesheet = StyleSheet.create((theme) => ({ streamItemWrapper: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, alignSelf: "center", paddingHorizontal: theme.spacing[2], }, diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 3fe17a7bee..bf13ca6330 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -23,7 +23,7 @@ import { type ViewStyle, } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useMutation } from "@tanstack/react-query"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { Check, ChevronDown, X } from "lucide-react-native"; @@ -55,7 +55,7 @@ import { useSessionStore } from "@/stores/session-store"; import { useRevealedText } from "@/hooks/use-revealed-text"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history"; -import { useSettings } from "@/hooks/use-settings"; +import { resolveContentMaxWidth, useSettings } from "@/hooks/use-settings"; import type { ToastApi } from "@/components/toast-host"; import { returnToTimelineTail } from "./timeline-tail-navigation"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -349,6 +349,7 @@ const AgentStreamViewComponent = forwardRef settings.autoExpandReasoning); const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel); const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled); + const contentMaxWidth = useSettings(resolveContentMaxWidth); const viewportRef = useRef(null); const pendingClientMessageIds = useMemo( () => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)), @@ -1129,6 +1130,7 @@ const AgentStreamViewComponent = forwardRef ({ }, contentWrapper: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, alignSelf: "center", paddingHorizontal: theme.spacing[2], }, @@ -1643,7 +1645,7 @@ const stylesheet = StyleSheet.create((theme) => ({ }, streamItemWrapper: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, alignSelf: "center", paddingHorizontal: theme.spacing[2], }, diff --git a/packages/app/src/agent-stream/web-virtualization.test.ts b/packages/app/src/agent-stream/web-virtualization.test.ts index 0e45efa65d..fc3d82b35c 100644 --- a/packages/app/src/agent-stream/web-virtualization.test.ts +++ b/packages/app/src/agent-stream/web-virtualization.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme"; import type { StreamItem } from "@/types/stream"; import { clearAssistantImageMetadataCache, @@ -123,8 +124,8 @@ describe("splitWebVirtualizedHistory", () => { describe("estimateStreamItemHeight", () => { it("uses compact estimates for collapsed tool sequence rows", () => { - expect(estimateStreamItemHeight(toolCall("tool", 1))).toBe(40); - expect(estimateStreamItemHeight(thought("thought", 2))).toBe(40); + expect(estimateStreamItemHeight(toolCall("tool", 1), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40); + expect(estimateStreamItemHeight(thought("thought", 2), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40); }); it("uses a larger estimate for user messages with image attachments", () => { @@ -146,7 +147,7 @@ describe("estimateStreamItemHeight", () => { ], }; - expect(estimateStreamItemHeight(item)).toBe(220); + expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBe(220); }); it("uses cached assistant image metadata when available", () => { @@ -165,7 +166,7 @@ describe("estimateStreamItemHeight", () => { timestamp: createTimestamp(2), }; - expect(estimateStreamItemHeight(item)).toBeGreaterThan(220); + expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBeGreaterThan(220); }); }); diff --git a/packages/app/src/agent-stream/web-virtualization.ts b/packages/app/src/agent-stream/web-virtualization.ts index 21ba5d64dd..13bc737e5f 100644 --- a/packages/app/src/agent-stream/web-virtualization.ts +++ b/packages/app/src/agent-stream/web-virtualization.ts @@ -58,12 +58,12 @@ export interface WebVirtualizedHistoryWindow { mountedEntries: IndexedStreamItem[]; } -export function estimateStreamItemHeight(item: StreamItem): number { +export function estimateStreamItemHeight(item: StreamItem, contentMaxWidth: number): number { switch (item.kind) { case "user_message": return item.images && item.images.length > 0 ? 220 : 96; case "assistant_message": - return estimateAssistantMessageHeightFromCache(item.text) ?? 220; + return estimateAssistantMessageHeightFromCache(item.text, contentMaxWidth) ?? 220; case "tool_call": return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE; case "thought": diff --git a/packages/app/src/appearance/apply.test.ts b/packages/app/src/appearance/apply.test.ts index 9780d00738..e18eaa6555 100644 --- a/packages/app/src/appearance/apply.test.ts +++ b/packages/app/src/appearance/apply.test.ts @@ -37,6 +37,7 @@ interface FakeTheme { "4xl": number; }; lineHeight: { diff: number }; + contentMaxWidth: number; colors: { foreground: string; syntax: Record }; } @@ -56,6 +57,7 @@ function makeFakeTheme(): FakeTheme { "4xl": 26, }, lineHeight: { diff: 22 }, + contentMaxWidth: 820, colors: { foreground: "#fff", syntax: {} }, }; } @@ -67,6 +69,7 @@ function makeInput(overrides: Partial = {}): AppearanceInput { uiBaseFontSize: 14, contentFontSize: 15, codeFontSize: 12, + contentMaxWidth: 820, syntaxTheme: "one", ...overrides, }; @@ -102,6 +105,12 @@ describe("applyAppearance", () => { ]); }); + it("patches the content max width into the theme", () => { + applyAppearance(makeInput({ contentMaxWidth: 1600 })); + + expect(runCapturedUpdater().contentMaxWidth).toBe(1600); + }); + it("resolves an empty UI font family to the default stack", () => { applyAppearance(makeInput({ uiFontFamily: "" })); diff --git a/packages/app/src/appearance/apply.ts b/packages/app/src/appearance/apply.ts index 4d840265f0..04032a00b0 100644 --- a/packages/app/src/appearance/apply.ts +++ b/packages/app/src/appearance/apply.ts @@ -17,6 +17,7 @@ export interface AppearanceInput { uiBaseFontSize: number; // already clamped contentFontSize: number; // already clamped codeFontSize: number; // already clamped + contentMaxWidth: number; // already clamped, default resolved syntaxTheme: SyntaxThemeId; } @@ -55,7 +56,7 @@ function scaleFontSize( * always current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. * * The updater preserves the active theme wholesale (surfaces, accents, - * terminal) and only patches the font ramp and syntax palette. + * terminal) and only patches the font ramp, content width, and syntax palette. * `updateTheme` replaces the stored theme rather than merging, so we spread * `...t` first. */ @@ -86,6 +87,7 @@ export function applyAppearance(input: AppearanceInput): void { fontFamily, fontSize, lineHeight, + contentMaxWidth: input.contentMaxWidth, colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) }, }; } @@ -94,6 +96,7 @@ export function applyAppearance(input: AppearanceInput): void { fontFamily, fontSize, lineHeight, + contentMaxWidth: input.contentMaxWidth, colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) }, }; }); diff --git a/packages/app/src/appearance/provider.tsx b/packages/app/src/appearance/provider.tsx index 9b09d987c9..d52864e435 100644 --- a/packages/app/src/appearance/provider.tsx +++ b/packages/app/src/appearance/provider.tsx @@ -8,7 +8,12 @@ import { useState, } from "react"; import { UnistylesRuntime } from "react-native-unistyles"; -import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings"; +import { + DEFAULT_THEME_PREFERENCE, + resolveContentMaxWidth, + useAppSettings, + type AppSettings, +} from "@/hooks/use-settings"; import { rememberPluginThemeHost, usePluginThemeCatalog, @@ -68,6 +73,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) { uiBaseFontSize: settings.uiBaseFontSize, contentFontSize: settings.contentFontSize, codeFontSize: settings.codeFontSize, + contentMaxWidth: resolveContentMaxWidth({ contentMaxWidth: settings.contentMaxWidth }), syntaxTheme: settings.syntaxTheme, }); setHasAppliedAppearance(true); @@ -80,6 +86,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) { settings.uiBaseFontSize, settings.contentFontSize, settings.codeFontSize, + settings.contentMaxWidth, settings.syntaxTheme, ]); diff --git a/packages/app/src/components/archived-agent-callout.tsx b/packages/app/src/components/archived-agent-callout.tsx index 5889ebbb26..b5f6a3ea54 100644 --- a/packages/app/src/components/archived-agent-callout.tsx +++ b/packages/app/src/components/archived-agent-callout.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react"; import { View, Text } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { FOOTER_HEIGHT } from "@/constants/layout"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { KeyboardTranslateView } from "@/keyboard/shift"; import { Button } from "@/components/ui/button"; @@ -77,7 +77,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, inputAreaContent: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, }, callout: { flexDirection: "row", diff --git a/packages/app/src/composer/dock/index.native.tsx b/packages/app/src/composer/dock/index.native.tsx index 49fb6a15a4..91bb98e7a8 100644 --- a/packages/app/src/composer/dock/index.native.tsx +++ b/packages/app/src/composer/dock/index.native.tsx @@ -2,7 +2,8 @@ import { ComposerDockBackground } from "./internal/background"; export { ComposerDockBackground } from "./internal/background"; import { ScrollView } from "@/components/ui/scroll-view"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { HEADER_INNER_HEIGHT } from "@/constants/layout"; +import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings"; import { KeyboardTranslateView } from "@/keyboard/shift"; import { createContext, useCallback, useContext, type ReactNode } from "react"; import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native"; @@ -89,6 +90,7 @@ export function ComposerDock({ centered = false, }: ComposerDockProps) { const insets = useSafeAreaInsets(); + const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings); // Preserve the existing centered form's visual balance on tablets. const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0; if (centered) { @@ -98,7 +100,7 @@ export function ComposerDock({ bottomInset={bottomInset} centered > - + {content} @@ -137,13 +139,15 @@ export function ComposerDock({ ); } +// Plain React Native styles: these land on Reanimated views, which must not carry +// theme-driven Unistyles (docs/unistyles.md). The content width arrives inline. const dockStyles = StyleSheet.create({ viewport: { flex: 1, overflow: "hidden" }, surface: { flex: 1 }, content: { flex: 1, justifyContent: "flex-end" }, composer: { width: "100%", flexShrink: 1 }, centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" }, - centered: { flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH }, + centered: { flexShrink: 1, width: "100%" }, // Reserve the composer's own capped height before the setup scroll view shrinks. centeredComposer: { width: "100%", flexShrink: 0 }, setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 }, diff --git a/packages/app/src/composer/dock/index.tsx b/packages/app/src/composer/dock/index.tsx index 39a8e24cbf..d39439f325 100644 --- a/packages/app/src/composer/dock/index.tsx +++ b/packages/app/src/composer/dock/index.tsx @@ -1,8 +1,9 @@ export { ComposerDockBackground } from "./internal/background"; import { ScrollView } from "@/components/ui/scroll-view"; import type { ReactNode } from "react"; -import { View, StyleSheet } from "react-native"; -import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { HEADER_INNER_HEIGHT } from "@/constants/layout"; interface ComposerDockProps { children: [ReactNode, ReactNode, ReactNode?]; @@ -35,7 +36,7 @@ export function ComposerDock({ ); } -const styles = StyleSheet.create({ +const styles = StyleSheet.create((theme) => ({ surface: { flex: 1, overflow: "hidden" }, content: { flex: 1, justifyContent: "flex-end" }, composer: { width: "100%", flexShrink: 1 }, @@ -45,7 +46,7 @@ const styles = StyleSheet.create({ justifyContent: "center", paddingBottom: HEADER_INNER_HEIGHT + 24, }, - form: { flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH }, + form: { flexShrink: 1, width: "100%", maxWidth: theme.contentMaxWidth }, centeredComposer: { flexShrink: 0 }, setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 }, -}); +})); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 519e22e940..6e9c45eab5 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -40,11 +40,7 @@ import { useWorkspaceAttachmentsStore, } from "@/attachments/workspace-attachments-store"; import type { UserMessageImageAttachment } from "@/types/stream"; -import { - COMPACT_FORM_FACTOR_WIDTH, - MAX_CONTENT_WIDTH, - useIsCompactFormFactor, -} from "@/constants/layout"; +import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { buildWorkspaceTabPersistenceKey, @@ -737,7 +733,7 @@ const styles = StyleSheet.create((theme) => ({ }, importPillContent: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, flexDirection: "row", }, errorContainer: { diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index ef6e27d3ac..28d23ae214 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -41,7 +41,7 @@ import { Paperclip, } from "lucide-react-native"; import * as Clipboard from "expo-clipboard"; -import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { FOOTER_HEIGHT } from "@/constants/layout"; import { AgentControls, DraftAgentControls, @@ -2542,7 +2542,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ inputAreaContent: { flexShrink: 1, width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, gap: theme.spacing[3], }, messageInputContainer: { diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 3ff9e765c6..e33cd6ba53 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -11,7 +11,6 @@ import { } from "@/components/ui/menu"; import { StatusRing } from "@/components/status-ring"; import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry"; -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { getStatusDotColor } from "@/utils/status-dot-color"; import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry"; @@ -328,7 +327,7 @@ const styles = StyleSheet.create((theme) => { }, track: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 3e159041e2..721708715c 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -11,8 +11,6 @@ export const HEADER_INNER_HEIGHT_MOBILE = 56; export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36; export const HEADER_TOP_PADDING_MOBILE = 8; -// Max width for chat content (stream view, input area, new agent form) -export const MAX_CONTENT_WIDTH = 820; export const COMPACT_FORM_FACTOR_WIDTH = 500; // Settings uses the canonical desktop list + detail layout. Its sidebar and diff --git a/packages/app/src/file-pane/markdown-preview/index.tsx b/packages/app/src/file-pane/markdown-preview/index.tsx index f1247f1c91..e8d172c61b 100644 --- a/packages/app/src/file-pane/markdown-preview/index.tsx +++ b/packages/app/src/file-pane/markdown-preview/index.tsx @@ -2,7 +2,6 @@ import { useMemo } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { MarkdownRenderer } from "@/components/markdown/renderer"; -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { parseMarkdownPreviewDocument } from "./document"; export function FileMarkdownPreview({ source }: { source: string }) { @@ -10,7 +9,7 @@ export function FileMarkdownPreview({ source }: { source: string }) { return ( - + {document.frontMatter.length > 0 ? ( {document.frontMatter.map((row, index) => ( @@ -49,7 +48,7 @@ const styles = StyleSheet.create((theme) => ({ }, readingFrame: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, alignSelf: "center", paddingHorizontal: theme.spacing[2], }, diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index 704af34976..66a8e767b1 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -17,22 +17,27 @@ import { DEFAULT_CLIENT_SETTINGS, DEFAULT_CODE_FONT_SIZE, DEFAULT_CONTENT_FONT_SIZE, + DEFAULT_CONTENT_MAX_WIDTH, DEFAULT_TERMINAL_SCROLLBACK_LINES, DEFAULT_THEME_PREFERENCE, DEFAULT_UI_BASE_FONT_SIZE, MAX_CODE_FONT_SIZE, MAX_CONTENT_FONT_SIZE, + MAX_CONTENT_MAX_WIDTH, MAX_TERMINAL_SCROLLBACK_LINES, MAX_UI_BASE_FONT_SIZE, MIN_CODE_FONT_SIZE, MIN_CONTENT_FONT_SIZE, + MIN_CONTENT_MAX_WIDTH, MIN_TERMINAL_SCROLLBACK_LINES, MIN_UI_BASE_FONT_SIZE, loadAppSettingsFromStorage as loadAppSettingsFromStoragePure, loadSettingsFromStorage as loadSettingsFromStoragePure, normalizeAppSettings, parseClampedFontSize, + parseContentMaxWidth, parseTerminalScrollbackLines, + resolveContentMaxWidth, sanitizeFontFamily, saveAppSettings as saveAppSettingsPure, type AppSettings, @@ -56,19 +61,24 @@ export { DEFAULT_CLIENT_SETTINGS, DEFAULT_CODE_FONT_SIZE, DEFAULT_CONTENT_FONT_SIZE, + DEFAULT_CONTENT_MAX_WIDTH, DEFAULT_TERMINAL_SCROLLBACK_LINES, DEFAULT_THEME_PREFERENCE, DEFAULT_UI_BASE_FONT_SIZE, MAX_CODE_FONT_SIZE, MAX_CONTENT_FONT_SIZE, + MAX_CONTENT_MAX_WIDTH, MAX_TERMINAL_SCROLLBACK_LINES, MAX_UI_BASE_FONT_SIZE, MIN_CODE_FONT_SIZE, MIN_CONTENT_FONT_SIZE, + MIN_CONTENT_MAX_WIDTH, MIN_TERMINAL_SCROLLBACK_LINES, MIN_UI_BASE_FONT_SIZE, parseClampedFontSize, + parseContentMaxWidth, parseTerminalScrollbackLines, + resolveContentMaxWidth, sanitizeFontFamily, }; export type { diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 51e2831338..df738d7247 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -14,6 +14,7 @@ import { loadSettingsFromStorage, parseClampedFontSize, parseTerminalScrollbackLines, + resolveContentMaxWidth, saveAppSettings, type SettingsDeps, } from "./storage"; @@ -22,7 +23,7 @@ import { DEFAULT_SIDEBAR_ROW_ITEMS, SIDEBAR_ROW_ITEMS, } from "@/components/sidebar/display-preferences/row-items"; -import { THEME_OPTIONS } from "@/styles/theme"; +import { DEFAULT_CONTENT_MAX_WIDTH, THEME_OPTIONS } from "@/styles/theme"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; @@ -955,3 +956,49 @@ describe("parseClampedFontSize", () => { expect(parseClampedFontSize("abc", { min: 11, max: 24 })).toBeNull(); }); }); + +describe("content max width", () => { + it("follows the current default until the user picks a width", async () => { + const deps = makeDeps(); + const queryClient = new QueryClient(); + + await saveAppSettings({ queryClient, updates: { codeFontSize: 14 }, deps }); + + const stored = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null"); + expect(stored.contentMaxWidth).toBeNull(); + expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe( + DEFAULT_CONTENT_MAX_WIDTH, + ); + }); + + it("keeps a picked width and returns to the default when reset", async () => { + const deps = makeDeps(); + const queryClient = new QueryClient(); + + await saveAppSettings({ queryClient, updates: { contentMaxWidth: 1600 }, deps }); + expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe(1600); + + await saveAppSettings({ queryClient, updates: { contentMaxWidth: null }, deps }); + expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null").contentMaxWidth).toBe( + null, + ); + expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe( + DEFAULT_CONTENT_MAX_WIDTH, + ); + }); + + it("clamps stored widths and drops malformed ones to the default", async () => { + const load = (contentMaxWidth: unknown) => + loadAppSettingsFromStorage( + makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ contentMaxWidth }), + }), + }), + ); + + expect((await load(100_000)).contentMaxWidth).toBe(4000); + expect((await load(10)).contentMaxWidth).toBe(600); + expect((await load("wide")).contentMaxWidth).toBeNull(); + }); +}); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 7a47bc7e6c..7171505492 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -15,6 +15,7 @@ import { } from "@/components/sidebar/display-preferences/row-items"; import { isNative } from "@/constants/platform"; import { + DEFAULT_CONTENT_MAX_WIDTH, FONT_SIZE, PLUGIN_THEME_PREFERENCE, THEME_OPTIONS, @@ -63,6 +64,9 @@ export const DEFAULT_CODE_FONT_SIZE = 12; // == FONT_SIZE.code export const MIN_CODE_FONT_SIZE = 9; export const MAX_CODE_FONT_SIZE = 22; // line-height 1.5×22=33 stays safe export const MAX_FONT_FAMILY_LENGTH = 200; +export { DEFAULT_CONTENT_MAX_WIDTH }; +export const MIN_CONTENT_MAX_WIDTH = 600; +export const MAX_CONTENT_MAX_WIDTH = 4000; export interface AppSettings { theme: ThemePreference; @@ -78,6 +82,8 @@ export interface AppSettings { uiBaseFontSize: number; // clamped px, platform default 14 or 15 contentFontSize: number; // clamped px, platform default 15 or 16 codeFontSize: number; // clamped px, default 12 + /** Max width of chat and markdown content in px; null follows the current default. */ + contentMaxWidth: number | null; syntaxTheme: SyntaxThemeId; // default "one" workspaceTitleSource: WorkspaceTitleSource; sidebarWorkspaceTrailing: SidebarWorkspaceTrailing; @@ -132,6 +138,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { uiBaseFontSize: DEFAULT_UI_BASE_FONT_SIZE, contentFontSize: DEFAULT_CONTENT_FONT_SIZE, codeFontSize: DEFAULT_CODE_FONT_SIZE, + contentMaxWidth: null, syntaxTheme: "one", workspaceTitleSource: "title", sidebarWorkspaceTrailing: "diff", @@ -218,6 +225,10 @@ const StoredAppSettingsSchema = z codeFontSize: clampedNumber(MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE).catch( DEFAULT_CODE_FONT_SIZE, ), + contentMaxWidth: z + .null() + .or(clampedNumber(MIN_CONTENT_MAX_WIDTH, MAX_CONTENT_MAX_WIDTH)) + .catch(null), syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"), workspaceTitleSource: z.enum(["title", "branch"]).catch("title"), sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("diff"), @@ -457,6 +468,14 @@ export function parseTerminalScrollbackLines(value: unknown): number | null { ); } +export function parseContentMaxWidth(value: unknown): number | null { + return parseClampedFontSize(value, { min: MIN_CONTENT_MAX_WIDTH, max: MAX_CONTENT_MAX_WIDTH }); +} + +export function resolveContentMaxWidth(settings: Pick): number { + return settings.contentMaxWidth ?? DEFAULT_CONTENT_MAX_WIDTH; +} + export function parseClampedFontSize( value: unknown, bounds: { min: number; max: number }, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b32fa13c70..a418df723f 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2191,6 +2191,14 @@ export const ar: TranslationResources = { codeSizeHint: "يُستخدم للكود والفروقات ومخرجات الطرفية", codeSizeAccessibility: "حجم خط الكود", }, + layout: { + title: "التخطيط", + contentWidth: "عرض المحتوى", + contentWidthHint: "أقصى عرض للمحادثة وملفات Markdown على الشاشات العريضة", + contentWidthAccessibility: "عرض المحتوى بالبكسل", + reset: "إعادة تعيين", + resetAccessibility: "إعادة عرض المحتوى إلى الافتراضي", + }, syntax: { title: "بناء الجملة", highlightTheme: "تسليط الضوء على الموضوع", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 663058713a..09774ae914 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2315,6 +2315,14 @@ export const en = { codeSizeHint: "Used for code, diffs, and terminal output", codeSizeAccessibility: "Code font size", }, + layout: { + title: "Layout", + contentWidth: "Content width", + contentWidthHint: "Max width of chat and Markdown files on wide screens", + contentWidthAccessibility: "Content width in pixels", + reset: "Reset", + resetAccessibility: "Reset content width to default", + }, syntax: { title: "Syntax", highlightTheme: "Highlight theme", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 0d4dc5a43e..3227988de9 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2245,6 +2245,14 @@ export const es: TranslationResources = { codeSizeHint: "Se usa en código, diferencias y la salida del terminal", codeSizeAccessibility: "Tamaño de fuente del código", }, + layout: { + title: "Diseño", + contentWidth: "Ancho del contenido", + contentWidthHint: "Ancho máximo del chat y de los archivos Markdown en pantallas anchas", + contentWidthAccessibility: "Ancho del contenido en píxeles", + reset: "Restablecer", + resetAccessibility: "Restablecer el ancho del contenido", + }, syntax: { title: "Sintaxis", highlightTheme: "Tema destacado", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index e3721eb523..ca3343000f 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2248,6 +2248,14 @@ export const fr: TranslationResources = { codeSizeHint: "Utilisée pour le code, les diffs et la sortie du terminal", codeSizeAccessibility: "Taille de la police du code", }, + layout: { + title: "Mise en page", + contentWidth: "Largeur du contenu", + contentWidthHint: "Largeur maximale du chat et des fichiers Markdown sur les grands écrans", + contentWidthAccessibility: "Largeur du contenu en pixels", + reset: "Réinitialiser", + resetAccessibility: "Réinitialiser la largeur du contenu", + }, syntax: { title: "Syntaxe", highlightTheme: "Thème de surbrillance", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index beee86a4f4..31c4c8fb61 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2211,6 +2211,14 @@ export const ja: TranslationResources = { codeSizeHint: "コード、差分、ターミナル出力に使用されます", codeSizeAccessibility: "コードフォントサイズ", }, + layout: { + title: "レイアウト", + contentWidth: "コンテンツ幅", + contentWidthHint: "ワイド画面でのチャットと Markdown ファイルの最大幅", + contentWidthAccessibility: "コンテンツ幅 (ピクセル)", + reset: "リセット", + resetAccessibility: "コンテンツ幅をデフォルトに戻す", + }, syntax: { title: "構文ハイライト", highlightTheme: "ハイライトテーマ", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index d70c345137..cf5ca5ea41 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2202,6 +2202,14 @@ export const ko: TranslationResources = { codeSizeHint: "코드, diff 및 터미널 출력에 사용됩니다", codeSizeAccessibility: "코드 글꼴 크기", }, + layout: { + title: "레이아웃", + contentWidth: "콘텐츠 너비", + contentWidthHint: "와이드 화면에서 채팅과 Markdown 파일의 최대 너비", + contentWidthAccessibility: "콘텐츠 너비(픽셀)", + reset: "재설정", + resetAccessibility: "콘텐츠 너비를 기본값으로 재설정", + }, syntax: { title: "구문", highlightTheme: "강조 테마", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 0ab0df227d..5d4e10c14e 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2226,6 +2226,14 @@ export const ptBR: TranslationResources = { codeSizeHint: "Usado em código, diffs e saída do terminal", codeSizeAccessibility: "Tamanho da fonte de código", }, + layout: { + title: "Layout", + contentWidth: "Largura do conteúdo", + contentWidthHint: "Largura máxima do chat e dos arquivos Markdown em telas largas", + contentWidthAccessibility: "Largura do conteúdo em pixels", + reset: "Redefinir", + resetAccessibility: "Redefinir a largura do conteúdo", + }, syntax: { title: "Sintaxe", highlightTheme: "Tema de destaque", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 29090f3ad4..271eafaa28 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2231,6 +2231,14 @@ export const ru: TranslationResources = { codeSizeHint: "Используется для кода, диффов и вывода терминала", codeSizeAccessibility: "Размер шрифта кода", }, + layout: { + title: "Макет", + contentWidth: "Ширина содержимого", + contentWidthHint: "Максимальная ширина чата и файлов Markdown на широких экранах", + contentWidthAccessibility: "Ширина содержимого в пикселях", + reset: "Сбросить", + resetAccessibility: "Сбросить ширину содержимого", + }, syntax: { title: "Синтаксис", highlightTheme: "Тема подсветки", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index da4e6fdfb3..cdeb534e58 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2166,6 +2166,14 @@ export const zhCN: TranslationResources = { codeSizeHint: "用于代码、差异和终端输出", codeSizeAccessibility: "代码字号", }, + layout: { + title: "布局", + contentWidth: "内容宽度", + contentWidthHint: "宽屏上聊天和 Markdown 文件的最大宽度", + contentWidthAccessibility: "内容宽度(像素)", + reset: "重置", + resetAccessibility: "将内容宽度重置为默认值", + }, syntax: { title: "语法", highlightTheme: "高亮主题", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index ac114681c8..c0669972bf 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -38,11 +38,7 @@ import { getProviderIcon } from "@/components/provider-icons"; import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; -import { - COMPACT_FORM_FACTOR_WIDTH, - MAX_CONTENT_WIDTH, - useIsCompactFormFactor, -} from "@/constants/layout"; +import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft"; @@ -1750,7 +1746,7 @@ const styles = StyleSheet.create((theme) => ({ }, timelineSyncCalloutContent: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, }, timelineSyncCallout: { flexDirection: "row", diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 56049b9b14..6e98819323 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; @@ -18,7 +18,11 @@ import { import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useContributedThemes } from "@/appearance/provider"; -import { EditingTextInput as TextInput } from "@/components/ui/text-input"; +import { Button } from "@/components/ui/button"; +import { + EditingTextInput as TextInput, + type EditingTextInputHandle, +} from "@/components/ui/text-input"; import { MAX_CODE_FONT_SIZE, MAX_CONTENT_FONT_SIZE, @@ -27,6 +31,8 @@ import { MIN_CONTENT_FONT_SIZE, MIN_UI_BASE_FONT_SIZE, parseClampedFontSize, + parseContentMaxWidth, + resolveContentMaxWidth, sanitizeFontFamily, useAppSettings, type AppSettings, @@ -326,6 +332,74 @@ function FontSizeRow({ ); } +// --------------------------------------------------------------------------- +// Content width: numeric field (commit on blur/submit) + reset to the default +// --------------------------------------------------------------------------- + +interface ContentWidthRowProps { + value: AppSettings["contentMaxWidth"]; + onChange: (value: AppSettings["contentMaxWidth"]) => void; +} + +function ContentWidthRow({ value, onChange }: ContentWidthRowProps) { + const { t } = useTranslation(); + const width = resolveContentMaxWidth({ contentMaxWidth: value }); + // The field is uncontrolled, so a saved or reset width is written into it directly. + const input = useRef(null); + + useEffect(() => { + input.current?.replaceText(String(width)); + }, [width]); + + const commit = useCallback(() => { + const next = parseContentMaxWidth(input.current?.getText()) ?? width; + input.current?.replaceText(String(next)); + // Typing the width already in effect keeps following the default. + if (next !== width) { + onChange(next); + } + }, [onChange, width]); + + const reset = useCallback(() => { + onChange(null); + }, [onChange]); + + return ( + + + {t("settings.appearance.layout.contentWidth")} + + {t("settings.appearance.layout.contentWidthHint")} + + + + {value === null ? null : ( + + )} + + px + + + ); +} + // --------------------------------------------------------------------------- // Syntax highlight theme picker (commits immediately) // --------------------------------------------------------------------------- @@ -440,6 +514,13 @@ export function AppearanceSection() { [selectPluginTheme], ); + const handleContentMaxWidthChange = useCallback( + (contentMaxWidth: AppSettings["contentMaxWidth"]) => { + void updateSettings({ contentMaxWidth }); + }, + [updateSettings], + ); + const handleSyntaxThemeChange = useCallback( (syntaxTheme: SyntaxThemeId) => { void updateSettings({ syntaxTheme }); @@ -603,6 +684,14 @@ export function AppearanceSection() { /> + + + + + @@ -668,6 +757,19 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, textAlign: "right", }, + widthInput: { + width: 80, + minHeight: 36, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + textAlign: "right", + }, unit: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 7c4ba4a9a1..f58075b3a1 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -642,7 +642,10 @@ export const DEFAULT_MONO_FONT_STACK: string = Platform.select({ web: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", }); -// `fontSize`, `fontFamily`, and `lineHeight` are deliberately widened to plain +// Chat and markdown content column; the appearance updater patches the user's width in. +export const DEFAULT_CONTENT_MAX_WIDTH = 820; + +// `fontSize`, `fontFamily`, `lineHeight`, and `contentMaxWidth` are deliberately widened to plain // `number`/`string` (not narrowed by `as const`) so the appearance updater can patch // them at runtime via `UnistylesRuntime.updateTheme`. The remaining tokens keep their // literal types. @@ -651,6 +654,7 @@ interface CommonTheme { fontSize: Record; fontFamily: { ui: string; mono: string }; lineHeight: Record; + contentMaxWidth: number; iconSize: typeof ICON_SIZE; fontWeight: typeof FONT_WEIGHT; borderRadius: typeof BORDER_RADIUS; @@ -663,6 +667,7 @@ const commonTheme: CommonTheme = { fontSize: FONT_SIZE, fontFamily: { ui: DEFAULT_UI_FONT_STACK, mono: DEFAULT_MONO_FONT_STACK }, lineHeight: LINE_HEIGHT, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, iconSize: ICON_SIZE, fontWeight: FONT_WEIGHT, borderRadius: BORDER_RADIUS, diff --git a/packages/app/src/utils/assistant-image-metadata.test.ts b/packages/app/src/utils/assistant-image-metadata.test.ts index d4b99655f2..8f8cca2d3f 100644 --- a/packages/app/src/utils/assistant-image-metadata.test.ts +++ b/packages/app/src/utils/assistant-image-metadata.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme"; import { clearAssistantImageMetadataCache, estimateAssistantMessageHeightFromCache, @@ -52,6 +53,7 @@ describe("assistant image metadata", () => { expect( estimateAssistantMessageHeightFromCache( "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", + DEFAULT_CONTENT_MAX_WIDTH, ), ).toBeGreaterThan(220); }); @@ -60,8 +62,14 @@ describe("assistant image metadata", () => { const source = `data:image/png;base64,${"a".repeat(512)}`; setAssistantImageMetadata({ source }, { width: 1200, height: 800 }); - const imageOnlyHeight = estimateAssistantMessageHeightFromCache(`![Screenshot](${source})`); - const mixedHeight = estimateAssistantMessageHeightFromCache(`Text\n\n![Screenshot](${source})`); + const imageOnlyHeight = estimateAssistantMessageHeightFromCache( + `![Screenshot](${source})`, + DEFAULT_CONTENT_MAX_WIDTH, + ); + const mixedHeight = estimateAssistantMessageHeightFromCache( + `Text\n\n![Screenshot](${source})`, + DEFAULT_CONTENT_MAX_WIDTH, + ); expect(imageOnlyHeight).toBeGreaterThan(220); expect(mixedHeight).toBeGreaterThan(imageOnlyHeight ?? 0); diff --git a/packages/app/src/utils/assistant-image-metadata.ts b/packages/app/src/utils/assistant-image-metadata.ts index 9f6e159b0d..76b8eafbb1 100644 --- a/packages/app/src/utils/assistant-image-metadata.ts +++ b/packages/app/src/utils/assistant-image-metadata.ts @@ -1,4 +1,3 @@ -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { resolveAssistantImageSource } from "@/utils/assistant-image-source"; import { createImageSourceCacheKey } from "@/attachments/utils"; @@ -14,7 +13,7 @@ const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500; const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500; const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g; -const ASSISTANT_IMAGE_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 8; +const ASSISTANT_IMAGE_INSET = 8; const ASSISTANT_IMAGE_MIN_HEIGHT = 160; const ASSISTANT_IMAGE_BLOCK_GAP = 24; const ASSISTANT_MESSAGE_BASE_HEIGHT = 96; @@ -177,7 +176,10 @@ export function extractAssistantImageSources(markdown: string): string[] { return parsed.sources; } -export function estimateAssistantMessageHeightFromCache(markdown: string): number | null { +export function estimateAssistantMessageHeightFromCache( + markdown: string, + contentMaxWidth: number, +): number | null { const parsed = assistantImageParseCache.get(markdown) ?? parseAssistantImageMarkdown(markdown); if (parsed.sources.length === 0) { return null; @@ -189,7 +191,7 @@ export function estimateAssistantMessageHeightFromCache(markdown: string): numbe .map((metadata) => Math.max( ASSISTANT_IMAGE_MIN_HEIGHT, - Math.round(ASSISTANT_IMAGE_ESTIMATE_WIDTH / metadata.aspectRatio), + Math.round((contentMaxWidth - ASSISTANT_IMAGE_INSET) / metadata.aspectRatio), ), ); diff --git a/packages/app/src/utils/assistant-message-height-estimate.test.ts b/packages/app/src/utils/assistant-message-height-estimate.test.ts index 7c6822723c..8df70fe588 100644 --- a/packages/app/src/utils/assistant-message-height-estimate.test.ts +++ b/packages/app/src/utils/assistant-message-height-estimate.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme"; import { clearAssistantMessageHeightEstimateCache, estimateAssistantMessageHeightFromCache, @@ -27,7 +28,21 @@ describe("assistant message height estimate", () => { height: 41.1, }); - expect(estimateAssistantMessageHeightFromCache("First paragraph\n\nSecond paragraph")).toBe(97); + expect( + estimateAssistantMessageHeightFromCache( + "First paragraph\n\nSecond paragraph", + DEFAULT_CONTENT_MAX_WIDTH, + ), + ).toBe(97); + }); + + it("reads block heights measured at the configured content width", () => { + setAssistantMarkdownBlockHeight({ block: "Wide paragraph", width: 1584, height: 20 }); + + expect(estimateAssistantMessageHeightFromCache("Wide paragraph", 1600)).toBe(44); + expect( + estimateAssistantMessageHeightFromCache("Wide paragraph", DEFAULT_CONTENT_MAX_WIDTH), + ).toBeNull(); }); it("falls back to image metadata when markdown blocks are not measured", () => { @@ -41,6 +56,7 @@ describe("assistant message height estimate", () => { expect( estimateAssistantMessageHeightFromCache( "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", + DEFAULT_CONTENT_MAX_WIDTH, ), ).toBeGreaterThan(220); }); diff --git a/packages/app/src/utils/assistant-message-height-estimate.ts b/packages/app/src/utils/assistant-message-height-estimate.ts index 05d702fb41..638c961e01 100644 --- a/packages/app/src/utils/assistant-message-height-estimate.ts +++ b/packages/app/src/utils/assistant-message-height-estimate.ts @@ -1,9 +1,8 @@ -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { estimateAssistantMessageHeightFromCache as estimateAssistantImageMessageHeightFromCache } from "@/utils/assistant-image-metadata"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; const ASSISTANT_MARKDOWN_BLOCK_HEIGHT_CACHE_LIMIT = 1000; -const ASSISTANT_MARKDOWN_BLOCK_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 16; +const ASSISTANT_MARKDOWN_BLOCK_INSET = 16; const ASSISTANT_MESSAGE_VERTICAL_PADDING = 24; const ASSISTANT_MARKDOWN_BLOCK_GAP = 12; @@ -78,7 +77,10 @@ export function setAssistantMarkdownBlockHeight(input: { return height; } -function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number | null { +function estimateAssistantMarkdownBlockHeightFromCache( + markdown: string, + contentMaxWidth: number, +): number | null { const blocks = splitMarkdownBlocks(markdown); if (blocks.length === 0) { return null; @@ -88,7 +90,7 @@ function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number for (const block of blocks) { const key = createMarkdownBlockHeightKey({ block, - width: ASSISTANT_MARKDOWN_BLOCK_ESTIMATE_WIDTH, + width: contentMaxWidth - ASSISTANT_MARKDOWN_BLOCK_INSET, }); const cachedHeight = key ? assistantMarkdownBlockHeightCache.get(key) : undefined; if (cachedHeight === undefined) { @@ -104,10 +106,13 @@ function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number ); } -export function estimateAssistantMessageHeightFromCache(markdown: string): number | null { +export function estimateAssistantMessageHeightFromCache( + markdown: string, + contentMaxWidth: number, +): number | null { return ( - estimateAssistantMarkdownBlockHeightFromCache(markdown) ?? - estimateAssistantImageMessageHeightFromCache(markdown) + estimateAssistantMarkdownBlockHeightFromCache(markdown, contentMaxWidth) ?? + estimateAssistantImageMessageHeightFromCache(markdown, contentMaxWidth) ); }