Skip to content
Merged
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
- A draft in the message box keeps its full height when the window or the pane is resized, or
the chat width changes: the box used to keep the height of its old line breaks until the next
key press. ([#463](https://github.com/devswha/herdr-web-ui/pull/463))
- With a browser font size other than 16px (from 11.4px up), the Default chat width is never
wider than Wide.
Its widest was a fixed 960px while Wide follows the font size, so at a 13px font Wide (936px)
made the chat narrower than Default on a large monitor. Default's widest now follows the font
size too (960px at 16px, 1200px at 20px), and it is never narrower than Narrow's 820px: under
an 11.4px font that 820px is itself wider than Wide.
([#474](https://github.com/devswha/herdr-web-ui/pull/474))
- An update asked for in the first moments after the app starts, or right after another update,
waits until the start is over. It used to stop the app in the middle of its start-up check,
and the app then fell back to the source checkout or did not come up.
Expand Down
16 changes: 11 additions & 5 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ Comfortable values are `:root`; the final column is the complete compact overrid
| `--rail-w` | `3px` | — | Selected-row rail |
| `--hairline` | `1px` | — | Borders |
| `--content-w` | `820px` | — | Settings and dialog content |
| `--chat-w` | `--content-w`, then the pane's lane in px | — | Chat lane: transcript, composer column, held list. Settings → Chat width: Narrow `--content-w`; Default follows the pane (min 820px, max 960px, 71% of the pane between); Wide `72rem` (1152px); Full `100%` |
| `--chat-w` | `--content-w`, then the pane's lane as one length | — | Chat lane: transcript, composer column, held list. Settings → Chat width: Narrow `--content-w`; Default follows the pane (min 820px, max `60rem` = 960px, 71% of the pane between; the px floor wins where 60rem is under it); Wide `72rem` (1152px); Full `100%` |
| `--palette-w` | `640px` | — | Command palette |
| `--palette-top` | `12vh` | — | Palette top offset |

Expand Down Expand Up @@ -482,10 +482,16 @@ One set for both themes: the card is island black wherever it shows.

### Chat turn (`.chat-turn`)
- The chat lens is a centered `--chat-w` transcript over the still-attached terminal surface.
At the Default chat width the lane follows the pane: min 820px, max 960px, 71% of the pane
(`.terminal-stack`) between. `PaneTerminal` measures the pane and writes the lane on it as one px
length (`chatLaneWidth`, `lib/settings.ts`); `--chat-w` never holds that percentage, because each
column would resolve it against its own box and they would differ by their gutters.
At the Default chat width the lane follows the pane: min 820px, max 60rem (960px at a 16px
root), 71% of the pane (`.terminal-stack`) between. `PaneTerminal` measures the pane and writes
the lane on it as one length, `min(max(820px, 60rem), <the pane's share>px)` (`chatLaneLength`,
`lib/settings.ts`); `--chat-w` never holds that percentage, because each column would resolve
it against its own box and they would differ by their gutters.
The ceiling is in rem because Wide is (`72rem`), and it stays `60rem` inside the length, so
Default is never wider than Wide at a root font of 11.4px and up, also when that size changes
while the page is open (under 11.4px the 820px floor is itself wider than Wide's 72rem). The
floor stays in px (`--content-w` is px); under a 13.67px root, where 60rem is less than 820px, the
floor wins and Default is the same lane as Narrow.
Structured Claude/omp transcripts fall back to ANSI-stripped pane scrollback when unavailable.
- The register is Codex / gajae-code-app: a quiet document. User turns are right-aligned neutral
cards (`--bg-elevated`, a `--bubble-border` edge, `--radius-lg` on all four corners, ≤80% wide,
Expand Down
2 changes: 1 addition & 1 deletion scripts/ui-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ try {
if (process.env.UI_EVIDENCE_DIR) await page.screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "multiple-queue.png") });
// one column: the held list, the box and the conversation share their edges, and the status
// line is the box's own last row, inside it.
// The default Chat width follows the pane: min 820px, max 960px, 71% of the pane between. This
// The default Chat width follows the pane: min 820px, max 60rem (960px at this 16px root), 71% of the pane between. This
// pane is under 1148px, so the lane is its 820px floor, as at Narrow; at Wide the lane is wider
// than the pane and the column is the pane less its gutters. A larger window grows the lane
const chatWidth = async (name: string): Promise<void> => {
Expand Down
9 changes: 5 additions & 4 deletions src/components/PaneTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { RenderBoundary } from "./RenderBoundary.tsx";
import { Composer } from "./Composer.tsx";
import type { AgentStatus, ClientRole, ConversationMetadata, InteractivePrompt, ServerMessage } from "../../shared/protocol.ts";
import type { PaneView } from "../lib/actions.ts";
import { chatLaneWidth, useSettings, terminalTheme, type Palette, type ResolvedTheme } from "../lib/settings.ts";
import { chatLaneLength, useSettings, terminalTheme, type Palette, type ResolvedTheme } from "../lib/settings.ts";
import { loadFontStack, TERMINAL_FONT_STACK, terminalFontStack } from "../lib/fontFamily.ts";
import { useT } from "../lib/i18n.ts";
import { isAppShortcut } from "../lib/shortcuts.ts";
Expand Down Expand Up @@ -184,18 +184,19 @@ export function PaneTerminal({
const { settings, update: updateSettings } = useSettings();
const shortcutSettings = useRef(settings.shortcutOverrides);
shortcutSettings.current = settings.shortcutOverrides;
// Settings → Chat width, Default: the lane follows this pane. One px length on the stack, which
// Settings → Chat width, Default: the lane follows this pane. One length on the stack, which
// the transcript, the composer column, the held list and the menus all inherit: a percentage
// would resolve against each one's own box and leave them a gutter apart. The other steps are
// fixed and stay with the stylesheet (styles.css)
// fixed and stay with the stylesheet (styles.css). The lane's ceiling stays 60rem inside the
// length, so a change of the browser's font size moves it at once, as it moves Wide's 72rem
useLayoutEffect(() => {
const stack = stackRef.current;
if (!stack) return;
if (settings.chatWidth !== "default") {
stack.style.removeProperty("--chat-w");
return;
}
const apply = (): void => stack.style.setProperty("--chat-w", `${chatLaneWidth(stack.clientWidth)}px`);
const apply = (): void => stack.style.setProperty("--chat-w", chatLaneLength(stack.clientWidth));
apply();
const observer = new ResizeObserver(apply);
observer.observe(stack);
Expand Down
64 changes: 58 additions & 6 deletions src/lib/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { FONT_FAMILY_MAX_CHARS } from "./fontFamily.ts";
import { alertPrefs, CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_LANE_MAX, CHAT_LANE_MIN, CHAT_WIDTHS, chatFontSize, chatLaneWidth, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, quickReplyButtons, sanitizeSettings, terminalTheme, forgetPaneViews } from "./settings.ts";
import { alertPrefs, CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_LANE_MAX_REM, CHAT_LANE_MIN, CHAT_WIDTHS, chatFontSize, chatLaneLength, chatLaneWidth, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, quickReplyButtons, sanitizeSettings, terminalTheme, forgetPaneViews } from "./settings.ts";

it("keeps the screen wake lock off until this device explicitly enables it", () => {
expect(DEFAULT_SETTINGS.keepScreenOn).toBe(false);
Expand Down Expand Up @@ -54,7 +54,7 @@ describe("chat width", () => {
const css = (name: string): string => readFileSync(join(import.meta.dir, "..", name), "utf8");
const tokens = css("styles.css");
expect(tokens).toContain("--content-w: 820px;");
// unmeasured, the lane is the Default rule's floor; the measured one is chatLaneWidth's, in px
// unmeasured, the lane is the Default rule's floor; the measured one is chatLaneLength's
expect(tokens).toContain("--chat-w: var(--content-w);");
expect(tokens).toContain(`--content-w: ${CHAT_LANE_MIN}px;`);
const step = (name: string): string | undefined => new RegExp(`\\[data-chat-width="${name}"\\] \\{\\s*--chat-w: ([^;]+);`).exec(tokens)?.[1];
Expand All @@ -66,7 +66,9 @@ describe("chat width", () => {
// a percentage in the token would resolve against each column's own box; Full is the one
// step that means exactly that
expect([...tokens.matchAll(/--chat-w: ([^;]+);/g)].map((match) => match[1]).filter((value) => value?.includes("%"))).toEqual(["100%"]);
expect(css("components/PaneTerminal.tsx")).toContain("chatLaneWidth(");
expect(css("components/PaneTerminal.tsx")).toContain(`setProperty("--chat-w", chatLaneLength(`);
// the root font size is not read in JS: the ceiling is 60rem in the length itself
expect(css("components/PaneTerminal.tsx")).not.toContain("chatLaneWidth(");
// Settings and New workspace stay on --content-w. That the chat columns share the lane is
// measured in the browser (scripts/ui-regression.ts), not read from the stylesheets
for (const file of ["components/SettingsDialog.css", "components/NewSessionDialog.css"]) {
Expand All @@ -77,11 +79,11 @@ describe("chat width", () => {
});

describe("the default chat lane", () => {
it("is 71.43% of the pane, never under 820px or over 960px", () => {
it("is 71.43% of the pane, never under 820px or over 60rem (960px at a 16px root)", () => {
expect(CHAT_LANE_MIN).toBe(820);
expect(CHAT_LANE_MAX).toBe(960);
expect(CHAT_LANE_MAX_REM).toBe(60);
// the pane beside a 320px sidebar in a 1280, 1440, 1680 and 1920 window
expect([960, 1120, 1360, 1600].map(chatLaneWidth)).toEqual([820, 820, 960, 960]);
expect([960, 1120, 1360, 1600].map((pane) => chatLaneWidth(pane))).toEqual([820, 820, 960, 960]);
// between the two ends it follows the pane, in whole px
expect(chatLaneWidth(1148)).toBe(820);
expect(chatLaneWidth(1149)).toBe(821);
Expand All @@ -94,6 +96,56 @@ describe("the default chat lane", () => {
it("is the floor for a phone, an unmeasured pane or a width that is not a number", () => {
for (const width of [0, 390, 780, -1, Number.NaN, Number.POSITIVE_INFINITY]) expect(chatLaneWidth(width)).toBe(820);
});

it("has its ceiling in rem, so it is never wider than Wide's 72rem at a root font of 11.4px and up", () => {
const wide = (root: number): number => 72 * root;
// 16px: the sizes above, whether the root is passed or not
expect([960, 1149, 1300, 1344, 1600, 4000].map((pane) => chatLaneWidth(pane, 16))).toEqual([820, 821, 929, 960, 960, 960]);
// 20px: 60rem is 1200px, reached by a 1680px pane; Wide is 1440px
expect([1148, 1344, 1600, 1679, 1680, 4000].map((pane) => chatLaneWidth(pane, 20))).toEqual([820, 960, 1143, 1199, 1200, 1200]);
// 13px: 60rem is 780px, under the 820px floor. The floor wins: the lane is Narrow's at every
// pane, and Wide (936px) is still the wider one. Before, a large pane got 960px here
expect([960, 1149, 1344, 1600, 4000].map((pane) => chatLaneWidth(pane, 13))).toEqual([820, 820, 820, 820, 820]);
// the two ends meet at 820 / 60 = 13.67px; a root just over it has a ceiling just over the floor
expect(chatLaneWidth(4000, 14)).toBe(840);
// a fractional root
expect(chatLaneWidth(4000, 17.6)).toBe(1056);
for (const root of [13, 14, 16, 17.6, 20, 24]) {
for (const pane of [0, 960, 1344, 1920, 4000]) {
const lane = chatLaneWidth(pane, root);
expect(lane).toBeGreaterThanOrEqual(CHAT_LANE_MIN);
expect(lane).toBeLessThanOrEqual(wide(root));
}
}
});

it("resolves the ceiling at 16px when the root font size is not a usable number", () => {
for (const root of [0, -16, Number.NaN, Number.POSITIVE_INFINITY]) expect(chatLaneWidth(4000, root)).toBe(960);
});

it("is written as one length with the ceiling left in rem, so it follows the root font size without being measured again", () => {
expect(chatLaneLength(1600)).toBe("min(max(820px, 60rem), 1143px)");
expect(chatLaneLength(4000)).toBe("min(max(820px, 60rem), 2857px)");
for (const pane of [0, 390, -1, Number.NaN, Number.POSITIVE_INFINITY]) expect(chatLaneLength(pane)).toBe("min(max(820px, 60rem), 820px)");
// no percentage (each column would resolve it against its own box), and no root size baked in
// as px: the only px in it are the floor and the pane's share
const resolve = (length: string, root: number): number => {
const match = /^min\(max\((\d+)px, (\d+)rem\), (\d+)px\)$/.exec(length);
if (!match) throw new Error(`not the lane's form: ${length}`);
return Math.min(Math.max(Number(match[1]), Number(match[2]) * root), Number(match[3]));
};
// one length, written once at a 16px root, is right at every other root: a font size changed
// while the tab is open needs no new measurement. A px ceiling written at 16px stayed 960px
// at 13px, wider than Wide's 936px, until the pane was next resized
const written = chatLaneLength(4000);
expect([13, 14, 16, 17.6, 20].map((root) => resolve(written, root))).toEqual([820, 840, 960, 1056, 1200]);
for (const root of [13, 14, 16, 17.6, 20, 24]) {
for (const pane of [0, 960, 1149, 1344, 1600, 1920, 4000]) {
expect(resolve(chatLaneLength(pane), root)).toBe(chatLaneWidth(pane, root));
expect(resolve(chatLaneLength(pane), root)).toBeLessThanOrEqual(72 * root);
}
}
});
});

describe("font families", () => {
Expand Down
41 changes: 32 additions & 9 deletions src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type UsageGlance = "week" | "session";
* catppuccin: Catppuccin Mocha in dark, Latte in light */
export type Palette = "amber" | "report" | "charcoal" | "catppuccin";
/** the chat lane's widest: the transcript, the composer column and the held list share it (--chat-w in src/styles.css).
* narrow: 820px; default: follows the pane (chatLaneWidth); wide: 72rem; full: the pane, less the gutters */
* narrow: 820px; default: follows the pane, up to 60rem (chatLaneWidth); wide: 72rem; full: the pane, less the gutters */
export type ChatWidth = "narrow" | "default" | "wide" | "full";
export const CHAT_WIDTHS: readonly ChatWidth[] = ["narrow", "default", "wide", "full"];
/** the lens a pane opens in until it is switched there: auto is chat for an agent on a touch screen, else terminal */
Expand Down Expand Up @@ -170,22 +170,45 @@ export function chatFontSize(settings: Settings): number {
return settings.chatFontSize ?? CHAT_BASE_FONT[settings.density];
}

/** The Default chat lane never runs narrower or wider than this, in px. */
/** The Default chat lane never runs narrower than this, in px: --content-w, the Narrow step. */
export const CHAT_LANE_MIN = 820;
export const CHAT_LANE_MAX = 960;
/** ...or wider than this, in rem: 960px at a 16px root. In rem because Wide is (72rem, styles.css),
* so Default stays the narrower of the two at a root font of 11.39px and up; under that the
* 820px floor wins and is itself wider than Wide (see chatLaneLength). */
export const CHAT_LANE_MAX_REM = 60;
/** The share of its pane the Default chat lane takes between the two. */
export const CHAT_LANE_RATIO = 0.7143;
/** The root font size chatLaneWidth resolves the rem ceiling with when none is given. */
export const ROOT_FONT_PX = 16;

/** The Default lane's pane-following part in whole px, before the rem ceiling: 71.43% of the pane, min 820px. */
function chatLaneFollow(paneWidth: number): number {
if (!Number.isFinite(paneWidth)) return CHAT_LANE_MIN;
return Math.max(CHAT_LANE_MIN, Math.round(paneWidth * CHAT_LANE_RATIO));
}

/**
* The Default chat lane for a pane this wide, in whole px: 71.43% of the pane, min 820, max 960.
* A length, not a CSS percentage: the lane's columns sit in boxes of different widths (the
* The Default chat lane for a pane this wide, as the CSS length PaneTerminal writes to --chat-w:
* 71.43% of the pane, min 820px, max 60rem.
* A length with no percentage in it: the lane's columns sit in boxes of different widths (the
* transcript and the composer column inside a gutter, the held list and the menus outside it),
* and one px value is what keeps them equal. A pane narrower than the result is unaffected:
* and one length is what keeps them equal. A pane narrower than the result is unaffected:
* every column is min(100%, lane).
* The ceiling is left to the stylesheet engine as 60rem, not resolved here: Wide's 72rem follows
* the root font size the moment it changes, and so must this, or Default would be the wider of
* the two until the pane was next measured.
* Where the two ends cross (60rem is under 820px below a 13.67px root) the floor wins: Default is
* then the same lane as Narrow, never narrower than it, and still no wider than Wide down to an
* 11.39px root, under which Narrow's own 820px is already wider than Wide's 72rem.
*/
export function chatLaneWidth(paneWidth: number): number {
if (!Number.isFinite(paneWidth)) return CHAT_LANE_MIN;
return Math.min(CHAT_LANE_MAX, Math.max(CHAT_LANE_MIN, Math.round(paneWidth * CHAT_LANE_RATIO)));
export function chatLaneLength(paneWidth: number): string {
return `min(max(${CHAT_LANE_MIN}px, ${CHAT_LANE_MAX_REM}rem), ${chatLaneFollow(paneWidth)}px)`;
}

/** What chatLaneLength resolves to at this root font size, in px (960 at most at 16px): the rule in numbers. */
export function chatLaneWidth(paneWidth: number, rootFontPx: number = ROOT_FONT_PX): number {
const root = Number.isFinite(rootFontPx) && rootFontPx > 0 ? rootFontPx : ROOT_FONT_PX;
return Math.min(Math.max(CHAT_LANE_MIN, CHAT_LANE_MAX_REM * root), chatLaneFollow(paneWidth));
}

/** Only known keys with the right type survive: a stale or hand-edited record never breaks the UI. */
Expand Down
4 changes: 2 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@
--hairline: 1px;
--content-w: 820px;
/* the chat lane: transcript, composer column, held list (Settings → Chat width, lib/settings.ts).
At the Default step PaneTerminal writes the pane's own lane on .terminal-stack, in px
(chatLaneWidth: 71.43% of the pane, min 820px, max 960px); this is that rule's floor, for a
At the Default step PaneTerminal writes the pane's own lane on .terminal-stack, as one
length (chatLaneLength: 71.43% of the pane in px, min 820px, max 60rem); this is that rule's floor, for a
pane not measured yet. Never a percentage here: each column would resolve it against its
own box and the columns would differ by their gutters */
--chat-w: var(--content-w);
Expand Down