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.
This commit is contained in:
Mohamed Boudra
2026-09-29 13:49:03 +01:00
committed by GitHub
parent aff476b01b
commit cc744cc669
39 changed files with 516 additions and 64 deletions
@@ -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();
}
});
@@ -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<void> {
await openSettings(page);
await openSettingsSection(page, "appearance");
}
export async function setContentWidth(page: Page, width: number): Promise<void> {
const input = contentWidthInput(page);
await input.fill(String(width));
await input.press("Tab");
}
export async function resetContentWidth(page: Page): Promise<void> {
await resetContentWidthButton(page).click();
}
export async function expectDefaultContentWidthSetting(page: Page, width: number): Promise<void> {
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<void> {
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<void> {
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<unknown> {
return page.evaluate((key) => {
const raw = localStorage.getItem(key);
return raw === null
? undefined
: (JSON.parse(raw) as { contentMaxWidth?: unknown }).contentMaxWidth;
}, APP_SETTINGS_KEY);
}
@@ -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<string>(),
@@ -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) => (
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
@@ -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,
@@ -92,6 +92,8 @@ export interface StreamRenderInput {
listStyle: StyleProp<ViewStyle>;
baseListContentContainerStyle: StyleProp<ViewStyle>;
forwardListContentContainerStyle: StyleProp<ViewStyle>;
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
contentMaxWidth: number;
}
export interface ResolveStreamRenderStrategyInput {
@@ -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],
},
+6 -4
View File
@@ -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<AgentStreamViewHandle, AgentStreamVi
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
const contentMaxWidth = useSettings(resolveContentMaxWidth);
const viewportRef = useRef<StreamViewportHandle | null>(null);
const pendingClientMessageIds = useMemo(
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
@@ -1129,6 +1130,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
})}
</MessageOuterSpacingProvider>
<ChatOutlineRail
@@ -1622,7 +1624,7 @@ const stylesheet = StyleSheet.create((theme) => ({
},
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],
},
@@ -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);
});
});
@@ -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":
@@ -37,6 +37,7 @@ interface FakeTheme {
"4xl": number;
};
lineHeight: { diff: number };
contentMaxWidth: number;
colors: { foreground: string; syntax: Record<string, string> };
}
@@ -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> = {}): 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: "" }));
+4 -1
View File
@@ -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) },
};
});
+8 -1
View File
@@ -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,
]);
@@ -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",
@@ -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
>
<KeyboardTranslateView style={dockStyles.centered}>
<KeyboardTranslateView style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}>
<ComposerViewportContent style={dockStyles.composer}>
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
{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 },
+6 -5
View File
@@ -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 },
});
}));
@@ -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: {
+2 -2
View File
@@ -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: {
+1 -2
View File
@@ -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],
-2
View File
@@ -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
@@ -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 (
<View style={styles.outerGutter}>
<View style={styles.readingFrame}>
<View style={styles.readingFrame} testID="markdown-preview-frame">
{document.frontMatter.length > 0 ? (
<View style={styles.frontMatterTable} testID="markdown-front-matter">
{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],
},
@@ -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 {
@@ -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();
});
});
@@ -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<AppSettings, "contentMaxWidth">): number {
return settings.contentMaxWidth ?? DEFAULT_CONTENT_MAX_WIDTH;
}
export function parseClampedFontSize(
value: unknown,
bounds: { min: number; max: number },
+8
View File
@@ -2191,6 +2191,14 @@ export const ar: TranslationResources = {
codeSizeHint: "يُستخدم للكود والفروقات ومخرجات الطرفية",
codeSizeAccessibility: "حجم خط الكود",
},
layout: {
title: "التخطيط",
contentWidth: "عرض المحتوى",
contentWidthHint: "أقصى عرض للمحادثة وملفات Markdown على الشاشات العريضة",
contentWidthAccessibility: "عرض المحتوى بالبكسل",
reset: "إعادة تعيين",
resetAccessibility: "إعادة عرض المحتوى إلى الافتراضي",
},
syntax: {
title: "بناء الجملة",
highlightTheme: "تسليط الضوء على الموضوع",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -2211,6 +2211,14 @@ export const ja: TranslationResources = {
codeSizeHint: "コード、差分、ターミナル出力に使用されます",
codeSizeAccessibility: "コードフォントサイズ",
},
layout: {
title: "レイアウト",
contentWidth: "コンテンツ幅",
contentWidthHint: "ワイド画面でのチャットと Markdown ファイルの最大幅",
contentWidthAccessibility: "コンテンツ幅 (ピクセル)",
reset: "リセット",
resetAccessibility: "コンテンツ幅をデフォルトに戻す",
},
syntax: {
title: "構文ハイライト",
highlightTheme: "ハイライトテーマ",
+8
View File
@@ -2202,6 +2202,14 @@ export const ko: TranslationResources = {
codeSizeHint: "코드, diff 및 터미널 출력에 사용됩니다",
codeSizeAccessibility: "코드 글꼴 크기",
},
layout: {
title: "레이아웃",
contentWidth: "콘텐츠 너비",
contentWidthHint: "와이드 화면에서 채팅과 Markdown 파일의 최대 너비",
contentWidthAccessibility: "콘텐츠 너비(픽셀)",
reset: "재설정",
resetAccessibility: "콘텐츠 너비를 기본값으로 재설정",
},
syntax: {
title: "구문",
highlightTheme: "강조 테마",
+8
View File
@@ -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",
+8
View File
@@ -2231,6 +2231,14 @@ export const ru: TranslationResources = {
codeSizeHint: "Используется для кода, диффов и вывода терминала",
codeSizeAccessibility: "Размер шрифта кода",
},
layout: {
title: "Макет",
contentWidth: "Ширина содержимого",
contentWidthHint: "Максимальная ширина чата и файлов Markdown на широких экранах",
contentWidthAccessibility: "Ширина содержимого в пикселях",
reset: "Сбросить",
resetAccessibility: "Сбросить ширину содержимого",
},
syntax: {
title: "Синтаксис",
highlightTheme: "Тема подсветки",
+8
View File
@@ -2166,6 +2166,14 @@ export const zhCN: TranslationResources = {
codeSizeHint: "用于代码、差异和终端输出",
codeSizeAccessibility: "代码字号",
},
layout: {
title: "布局",
contentWidth: "内容宽度",
contentWidthHint: "宽屏上聊天和 Markdown 文件的最大宽度",
contentWidthAccessibility: "内容宽度(像素)",
reset: "重置",
resetAccessibility: "将内容宽度重置为默认值",
},
syntax: {
title: "语法",
highlightTheme: "高亮主题",
+2 -6
View File
@@ -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",
@@ -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<EditingTextInputHandle>(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 (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.appearance.layout.contentWidth")}</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.appearance.layout.contentWidthHint")}
</Text>
</View>
<View style={styles.sizeField}>
{value === null ? null : (
<Button
variant="ghost"
size="sm"
onPress={reset}
accessibilityLabel={t("settings.appearance.layout.resetAccessibility")}
>
{t("settings.appearance.layout.reset")}
</Button>
)}
<TextInput
ref={input}
initialValue={String(width)}
onBlur={commit}
onSubmitEditing={commit}
keyboardType="number-pad"
inputMode="numeric"
selectTextOnFocus
style={styles.widthInput}
accessibilityLabel={t("settings.appearance.layout.contentWidthAccessibility")}
/>
<Text style={styles.unit}>px</Text>
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// 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() {
/>
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.layout.title")}>
<View style={settingsStyles.card}>
<ContentWidthRow
value={settings.contentMaxWidth}
onChange={handleContentMaxWidthChange}
/>
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.syntax.title")}>
<View style={settingsStyles.card}>
<SyntaxRow value={settings.syntaxTheme} onChange={handleSyntaxThemeChange} />
@@ -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,
+6 -1
View File
@@ -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<keyof typeof FONT_SIZE, number>;
fontFamily: { ui: string; mono: string };
lineHeight: Record<keyof typeof LINE_HEIGHT, number>;
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,
@@ -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);
@@ -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),
),
);
@@ -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);
});
@@ -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)
);
}