diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index e908aa68..f0c3911e 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -294,8 +294,8 @@ describe("timestamp format defaults", () => { }); describe("chat font size defaults", () => { - it("defaults chat font size to 12px", () => { - expect(DEFAULT_CHAT_FONT_SIZE_PX).toBe(12); + it("defaults chat font size to 15px", () => { + expect(DEFAULT_CHAT_FONT_SIZE_PX).toBe(15); }); it("clamps chat font size updates into the supported range", () => { diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 31faa1ca..84de9e4f 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -47,7 +47,7 @@ const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; export const MIN_CHAT_FONT_SIZE_PX = 11; export const MAX_CHAT_FONT_SIZE_PX = 18; -export const DEFAULT_CHAT_FONT_SIZE_PX = 12; +export const DEFAULT_CHAT_FONT_SIZE_PX = 15; export const MIN_TERMINAL_FONT_SIZE_PX = 10; export const MAX_TERMINAL_FONT_SIZE_PX = 22; export const DEFAULT_TERMINAL_FONT_SIZE_PX = 12; diff --git a/apps/web/src/components/settings/SettingNumberInput.browser.tsx b/apps/web/src/components/settings/SettingNumberInput.browser.tsx new file mode 100644 index 00000000..e1deb965 --- /dev/null +++ b/apps/web/src/components/settings/SettingNumberInput.browser.tsx @@ -0,0 +1,114 @@ +// FILE: SettingNumberInput.browser.tsx +// Purpose: Browser regressions for manual settings number entry and commit-time normalization. +// Layer: Vitest browser tests + +import "../../index.css"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; +import { render } from "vitest-browser-react"; + +import { normalizeChatFontSizePx, normalizeTerminalFontSizePx } from "../../appSettings"; +import { SettingNumberInput } from "./SettingNumberInput"; + +describe("SettingNumberInput", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("keeps partial base-font input editable and commits on Enter", async () => { + const onCommit = vi.fn(); + await render( + , + ); + + const input = page.getByRole("spinbutton", { name: "Base font size in pixels" }); + await input.fill("1"); + expect(document.querySelector("input")?.value).toBe("1"); + expect(onCommit).not.toHaveBeenCalled(); + + await input.fill("14"); + expect(document.querySelector("input")?.value).toBe("14"); + await userEvent.keyboard("{Enter}"); + + expect(onCommit).toHaveBeenCalledOnce(); + expect(onCommit).toHaveBeenCalledWith(14); + }); + + it("reverts an empty draft and clamps out-of-range terminal input on blur", async () => { + const onCommit = vi.fn(); + await render( + , + ); + + const input = page.getByRole("spinbutton", { name: "Terminal font size in pixels" }); + await input.fill(""); + expect(document.querySelector("input")?.value).toBe(""); + document.querySelector("input")?.blur(); + await vi.waitFor(() => { + expect(document.querySelector("input")?.value).toBe("12"); + }); + expect(onCommit).not.toHaveBeenCalled(); + + await input.fill("99"); + document.querySelector("input")?.blur(); + await vi.waitFor(() => { + expect(document.querySelector("input")?.value).toBe("22"); + }); + expect(onCommit).toHaveBeenCalledOnce(); + expect(onCommit).toHaveBeenCalledWith(22); + }); + + it("does not overwrite an external update unless the focused draft was edited", async () => { + const onCommit = vi.fn(); + const view = (value: number) => ( + + ); + const screen = await render(view(15)); + const input = page.getByRole("spinbutton", { name: "Base font size in pixels" }); + + await input.click(); + await screen.rerender(view(16)); + expect(document.querySelector("input")?.value).toBe("15"); + document.querySelector("input")?.blur(); + await vi.waitFor(() => { + expect(document.querySelector("input")?.value).toBe("16"); + }); + expect(onCommit).not.toHaveBeenCalled(); + + await input.fill("14"); + await screen.rerender(view(17)); + expect(document.querySelector("input")?.value).toBe("14"); + document.querySelector("input")?.blur(); + await vi.waitFor(() => { + expect(onCommit).toHaveBeenCalledWith(14); + }); + }); +}); diff --git a/apps/web/src/components/settings/SettingNumberInput.tsx b/apps/web/src/components/settings/SettingNumberInput.tsx new file mode 100644 index 00000000..7bc5086f --- /dev/null +++ b/apps/web/src/components/settings/SettingNumberInput.tsx @@ -0,0 +1,101 @@ +// FILE: SettingNumberInput.tsx +// Purpose: Let settings number fields keep an editable draft and normalize only when committed. +// Layer: Settings UI components + +import { type ComponentProps, useCallback, useEffect, useRef, useState } from "react"; + +import { Input } from "~/components/ui/input"; + +type SettingNumberInputProps = Omit< + ComponentProps, + "value" | "defaultValue" | "onChange" +> & { + /** Committed settings value. */ + value: number; + /** Applies the setting's rounding and range rules when the draft is committed. */ + normalizeValue: (value: number) => number; + /** Called after blur or Enter when the normalized value changed. */ + onCommit: (value: number) => void; +}; + +export function SettingNumberInput({ + value, + normalizeValue, + onCommit, + onBlur, + onFocus, + onKeyDown, + ...inputProps +}: SettingNumberInputProps) { + const [draft, setDraft] = useState(String(value)); + const draftRef = useRef(String(value)); + const focusedRef = useRef(false); + const dirtyRef = useRef(false); + const valueRef = useRef(value); + valueRef.current = value; + const normalizeValueRef = useRef(normalizeValue); + normalizeValueRef.current = normalizeValue; + const onCommitRef = useRef(onCommit); + onCommitRef.current = onCommit; + + const replaceDraft = useCallback((nextDraft: string) => { + draftRef.current = nextDraft; + setDraft(nextDraft); + }, []); + + // Honor changes from elsewhere (including Restore defaults) without replacing text mid-edit. + useEffect(() => { + if (!focusedRef.current) { + dirtyRef.current = false; + replaceDraft(String(value)); + } + }, [replaceDraft, value]); + + const commit = useCallback(() => { + const wasDirty = dirtyRef.current; + dirtyRef.current = false; + if (!wasDirty) { + replaceDraft(String(valueRef.current)); + return; + } + + const nextValue = Number(draftRef.current.trim()); + if (draftRef.current.trim() === "" || !Number.isFinite(nextValue)) { + replaceDraft(String(valueRef.current)); + return; + } + + const normalizedValue = normalizeValueRef.current(nextValue); + replaceDraft(String(normalizedValue)); + if (normalizedValue !== valueRef.current) { + onCommitRef.current(normalizedValue); + } + }, [replaceDraft]); + + return ( + { + dirtyRef.current = true; + replaceDraft(event.target.value); + }} + onFocus={(event) => { + focusedRef.current = true; + dirtyRef.current = false; + onFocus?.(event); + }} + onBlur={(event) => { + focusedRef.current = false; + commit(); + onBlur?.(event); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + onKeyDown?.(event); + }} + /> + ); +} diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 094a5b51..839cf4e2 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -16,7 +16,7 @@ describe("estimateTimelineMessageHeight", () => { role: "assistant", text: "a".repeat(144), }), - ).toBe(117); + ).toBe(126.75); }); it("uses assistant sizing rules for system messages", () => { @@ -25,7 +25,7 @@ describe("estimateTimelineMessageHeight", () => { role: "system", text: "a".repeat(144), }), - ).toBe(117); + ).toBe(126.75); }); it("adds one attachment row for one or two user attachments", () => { @@ -35,7 +35,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1", type: "image" }], }), - ).toBe(180.5); + ).toBe(185.375); expect( estimateTimelineMessageHeight({ @@ -46,7 +46,7 @@ describe("estimateTimelineMessageHeight", () => { { id: "2", type: "image" }, ], }), - ).toBe(180.5); + ).toBe(185.375); }); it("keeps up to four user image attachments on one row", () => { @@ -60,7 +60,7 @@ describe("estimateTimelineMessageHeight", () => { { id: "3", type: "image" }, ], }), - ).toBe(180.5); + ).toBe(185.375); expect( estimateTimelineMessageHeight({ @@ -73,7 +73,7 @@ describe("estimateTimelineMessageHeight", () => { { id: "4", type: "image" }, ], }), - ).toBe(180.5); + ).toBe(185.375); }); it("adds a second attachment row for five user image attachments", () => { @@ -89,7 +89,7 @@ describe("estimateTimelineMessageHeight", () => { { id: "5", type: "image" }, ], }), - ).toBe(248.5); + ).toBe(253.375); }); it("caps long user message estimates to the shared 12-line clamp", () => { @@ -98,7 +98,7 @@ describe("estimateTimelineMessageHeight", () => { role: "user", text: "a".repeat(56 * 120), }), - ).toBe(351); + ).toBe(409.5); }); it("clamps messages with more than 12 explicit lines and includes the disclosure", () => { @@ -107,7 +107,7 @@ describe("estimateTimelineMessageHeight", () => { role: "user", text: Array.from({ length: 13 }, (_, index) => `line ${index + 1}`).join("\n"), }), - ).toBe(351); + ).toBe(409.5); }); it("counts explicit newlines for user message estimates", () => { @@ -116,7 +116,7 @@ describe("estimateTimelineMessageHeight", () => { role: "user", text: "first\nsecond\nthird", }), - ).toBe(155.5); + ).toBe(170.125); }); it("adds terminal context chrome without counting the hidden block as message text", () => { @@ -174,8 +174,8 @@ describe("estimateTimelineMessageHeight", () => { text: "a".repeat(52), }; - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(136); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(116.5); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(145.75); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(121.375); }); it("does not clamp user wrapping too aggressively on very narrow layouts", () => { @@ -184,8 +184,8 @@ describe("estimateTimelineMessageHeight", () => { text: "a".repeat(20), }; - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(155.5); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(116.5); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(194.5); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(121.375); }); it("uses narrower width to increase assistant line wrapping", () => { @@ -194,8 +194,8 @@ describe("estimateTimelineMessageHeight", () => { text: "a".repeat(200), }; - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(156); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(117); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(224.25); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(151.125); }); it("adds diff summary chrome to assistant message estimates", () => { @@ -208,7 +208,7 @@ describe("estimateTimelineMessageHeight", () => { }, { timelineWidthPx: 768 }, ), - ).toBe(209.5); + ).toBe(214.375); }); it("accounts for inline code spans that wrap wider than plain text", () => { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index d4da3a2f..00f2cd0f 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -88,6 +88,7 @@ import { Switch } from "../components/ui/switch"; import { toastManager } from "../components/ui/toast"; import { ThemePackEditor } from "../components/ThemePackEditor"; import { DebouncedSettingTextInput } from "../components/settings/DebouncedSettingTextInput"; +import { SettingNumberInput } from "../components/settings/SettingNumberInput"; import { SettingsCard, SettingsListRow, @@ -2075,7 +2076,7 @@ function SettingsRouteView() { } control={ - { - const nextValue = event.target.value.trim(); - if (nextValue.length === 0) return; + value={settings.chatFontSizePx} + normalizeValue={normalizeChatFontSizePx} + onCommit={(chatFontSizePx) => { updateSettings({ - chatFontSizePx: normalizeChatFontSizePx(Number(nextValue)), + chatFontSizePx, }); }} aria-label="Base font size in pixels" @@ -2116,7 +2116,7 @@ function SettingsRouteView() { } control={ - { - const nextValue = event.target.value.trim(); - if (nextValue.length === 0) return; + value={settings.terminalFontSizePx} + normalizeValue={normalizeTerminalFontSizePx} + onCommit={(terminalFontSizePx) => { updateSettings({ - terminalFontSizePx: normalizeTerminalFontSizePx(Number(nextValue)), + terminalFontSizePx, }); }} aria-label="Terminal font size in pixels"