From cc744cc669c6192b5b5a24e9e72171f2eedecc3b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 29 Sep 2026 13:49:03 +0100 Subject: [PATCH] feat(app): let users set the chat and Markdown content width (#5680) * feat(app): let users set the chat and Markdown content width Wide and ultrawide screens were stuck with an 820px reading column. The width is now an Appearance setting that flows through the Unistyles theme, like the font sizes, so every content surface picks it up. The stored value stays null until the user picks a width, so a future change to the default reaches everyone who never customized it. Reset writes null back. The native composer dock keeps plain React Native styles and takes the width inline because those styles land on Reanimated views, where theme-driven Unistyles crash on theme changes. * fix(app): pass the content width into the web stream viewport The viewport read app settings itself, which required a QueryClient in every render of the stream strategy. The agent stream view already reads settings, so it hands the width over with the rest of the render input. --- .../app/e2e/browser/content-width.spec.ts | 70 ++++++++++++ .../app/e2e/support/helpers/content-width.ts | 63 +++++++++++ .../src/agent-stream/strategy-web.test.tsx | 16 +++ .../app/src/agent-stream/strategy-web.tsx | 3 +- packages/app/src/agent-stream/strategy.ts | 2 + packages/app/src/agent-stream/turn-footer.tsx | 3 +- packages/app/src/agent-stream/view.tsx | 10 +- .../agent-stream/web-virtualization.test.ts | 9 +- .../src/agent-stream/web-virtualization.ts | 4 +- packages/app/src/appearance/apply.test.ts | 9 ++ packages/app/src/appearance/apply.ts | 5 +- packages/app/src/appearance/provider.tsx | 9 +- .../src/components/archived-agent-callout.tsx | 4 +- .../app/src/composer/dock/index.native.tsx | 10 +- packages/app/src/composer/dock/index.tsx | 11 +- .../app/src/composer/draft/workspace-tab.tsx | 8 +- packages/app/src/composer/index.tsx | 4 +- packages/app/src/composer/tracks.tsx | 3 +- packages/app/src/constants/layout.ts | 2 - .../src/file-pane/markdown-preview/index.tsx | 5 +- packages/app/src/hooks/use-settings/index.ts | 10 ++ .../src/hooks/use-settings/storage.test.ts | 49 +++++++- .../app/src/hooks/use-settings/storage.ts | 19 ++++ packages/app/src/i18n/resources/ar.ts | 8 ++ packages/app/src/i18n/resources/en.ts | 8 ++ packages/app/src/i18n/resources/es.ts | 8 ++ packages/app/src/i18n/resources/fr.ts | 8 ++ packages/app/src/i18n/resources/ja.ts | 8 ++ packages/app/src/i18n/resources/ko.ts | 8 ++ packages/app/src/i18n/resources/pt-BR.ts | 8 ++ packages/app/src/i18n/resources/ru.ts | 8 ++ packages/app/src/i18n/resources/zh-CN.ts | 8 ++ packages/app/src/panels/agent-panel.tsx | 8 +- .../appearance/appearance-section.tsx | 106 +++++++++++++++++- packages/app/src/styles/theme.ts | 7 +- .../utils/assistant-image-metadata.test.ts | 12 +- .../app/src/utils/assistant-image-metadata.ts | 10 +- .../assistant-message-height-estimate.test.ts | 18 ++- .../assistant-message-height-estimate.ts | 19 ++-- 39 files changed, 516 insertions(+), 64 deletions(-) create mode 100644 packages/app/e2e/browser/content-width.spec.ts create mode 100644 packages/app/e2e/support/helpers/content-width.ts 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) ); }