Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/components/settings/SettingNumberInput.browser.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SettingNumberInput
type="number"
value={15}
min={11}
max={18}
step={1}
normalizeValue={normalizeChatFontSizePx}
onCommit={onCommit}
aria-label="Base font size in pixels"
/>,
);

const input = page.getByRole("spinbutton", { name: "Base font size in pixels" });
await input.fill("1");
expect(document.querySelector<HTMLInputElement>("input")?.value).toBe("1");
expect(onCommit).not.toHaveBeenCalled();

await input.fill("14");
expect(document.querySelector<HTMLInputElement>("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(
<SettingNumberInput
type="number"
value={12}
min={10}
max={22}
step={1}
normalizeValue={normalizeTerminalFontSizePx}
onCommit={onCommit}
aria-label="Terminal font size in pixels"
/>,
);

const input = page.getByRole("spinbutton", { name: "Terminal font size in pixels" });
await input.fill("");
expect(document.querySelector<HTMLInputElement>("input")?.value).toBe("");
document.querySelector<HTMLInputElement>("input")?.blur();
await vi.waitFor(() => {
expect(document.querySelector<HTMLInputElement>("input")?.value).toBe("12");
});
expect(onCommit).not.toHaveBeenCalled();

await input.fill("99");
document.querySelector<HTMLInputElement>("input")?.blur();
await vi.waitFor(() => {
expect(document.querySelector<HTMLInputElement>("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) => (
<SettingNumberInput
type="number"
value={value}
min={11}
max={18}
step={1}
normalizeValue={normalizeChatFontSizePx}
onCommit={onCommit}
aria-label="Base font size in pixels"
/>
);
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<HTMLInputElement>("input")?.value).toBe("15");
document.querySelector<HTMLInputElement>("input")?.blur();
await vi.waitFor(() => {
expect(document.querySelector<HTMLInputElement>("input")?.value).toBe("16");
});
expect(onCommit).not.toHaveBeenCalled();

await input.fill("14");
await screen.rerender(view(17));
expect(document.querySelector<HTMLInputElement>("input")?.value).toBe("14");
document.querySelector<HTMLInputElement>("input")?.blur();
await vi.waitFor(() => {
expect(onCommit).toHaveBeenCalledWith(14);
});
});
});
101 changes: 101 additions & 0 deletions apps/web/src/components/settings/SettingNumberInput.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Input>,
"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 (
<Input
{...inputProps}
value={draft}
onChange={(event) => {
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);
}}
/>
);
}
34 changes: 17 additions & 17 deletions apps/web/src/components/timelineHeight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -35,7 +35,7 @@ describe("estimateTimelineMessageHeight", () => {
text: "hello",
attachments: [{ id: "1", type: "image" }],
}),
).toBe(180.5);
).toBe(185.375);

expect(
estimateTimelineMessageHeight({
Expand All @@ -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", () => {
Expand All @@ -60,7 +60,7 @@ describe("estimateTimelineMessageHeight", () => {
{ id: "3", type: "image" },
],
}),
).toBe(180.5);
).toBe(185.375);

expect(
estimateTimelineMessageHeight({
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
Loading
Loading