Compare commits

...

1 Commits

Author SHA1 Message Date
Hubert Zub 3490c5be49 setting-for-wide-chat
Signed-off-by: Hubert Zub <hubert.zub@gmail.com>
2026-08-10 13:04:34 +00:00
7 changed files with 235 additions and 7 deletions
+34
View File
@@ -152,6 +152,16 @@
--composer-shadow: 0 0 12px -6px rgb(0 0 0 / 12%);
--composer-shadow-focus: 0 0 20px -6px rgb(0 0 0 / 12%);
/* Chat column + bubble widths (see Appearance settings /
* lib/chatWidthPreferences.ts). ChatPage reads these via
* `max-w-[var(--chat-col-max)]` etc. Standard mode is the readable centered
* column, stepping up on ultrawide screens (media queries below, scoped to
* :not([data-chat-width="wide"])); wide mode overrides all three below to
* fill the width, holding bubbles to 80% so prose stays legible. */
--chat-col-max: 48rem; /* 3xl */
--chat-user-bubble-max: 640px;
--chat-assistant-bubble-max: 48rem;
/* User-controlled UI font family (see Appearance settings /
* lib/uiFontPreferences.ts). Deliberately left unset here so the `html` rule's
* `var(--ui-font-family, var(--font-sans))` falls back to the system stack;
@@ -306,6 +316,30 @@
--sidebar-active-foreground: var(--sidebar-foreground);
}
/* Standard chat width steps up on ultrawide screens (matches the old
* max-w-3xl/4xl/5xl ramp). Scoped to non-wide so wide mode keeps its full
* width. Assistant bubbles deliberately don't grow — long prose stays
* readable — so only the column widens. */
@media (min-width: 1921px) {
:root:not([data-chat-width="wide"]) {
--chat-col-max: 56rem; /* 4xl */
}
}
@media (min-width: 2561px) {
:root:not([data-chat-width="wide"]) {
--chat-col-max: 64rem; /* 5xl */
}
}
/* Wide chat: the column and composer fill the available width; bubbles hold to
* 80% of it so user/assistant messages stay easy to discern from full-width
* content. */
:root[data-chat-width="wide"] {
--chat-col-max: 100%;
--chat-user-bubble-max: 80%;
--chat-assistant-bubble-max: 80%;
}
.dark {
/* Dark mode tokens — semi-transparent for glassmorphism backdrop-filter */
--ui-shadow-neutral-rgb: 0 0 0;
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it } from "vitest";
import {
applyChatWidth,
CHAT_WIDTH_DEFAULT,
normalizeChatWidth,
readChatWidth,
writeChatWidth,
} from "./chatWidthPreferences";
const STORAGE_KEY = "omnigent:chat-width";
afterEach(() => {
localStorage.clear();
document.documentElement.removeAttribute("data-chat-width");
});
describe("chatWidthPreferences — read/write", () => {
it("returns standard when nothing is stored", () => {
expect(readChatWidth()).toBe(CHAT_WIDTH_DEFAULT);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it("stores wide and clears the key for standard", () => {
writeChatWidth("wide");
expect(readChatWidth()).toBe("wide");
expect(localStorage.getItem(STORAGE_KEY)).toBe("wide");
writeChatWidth("standard");
expect(readChatWidth()).toBe("standard");
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
});
describe("normalizeChatWidth", () => {
it("passes through valid values", () => {
expect(normalizeChatWidth("standard")).toBe("standard");
expect(normalizeChatWidth("wide")).toBe("wide");
});
it("maps unknown, null, and garbage to standard", () => {
expect(normalizeChatWidth("full")).toBe("standard");
expect(normalizeChatWidth("bogus")).toBe("standard");
expect(normalizeChatWidth(null)).toBe("standard");
expect(normalizeChatWidth(undefined)).toBe("standard");
});
});
describe("applyChatWidth", () => {
it("sets data-chat-width for wide and removes it for standard", () => {
applyChatWidth("wide");
expect(document.documentElement.getAttribute("data-chat-width")).toBe("wide");
applyChatWidth("standard");
expect(document.documentElement.hasAttribute("data-chat-width")).toBe(false);
});
});
+77
View File
@@ -0,0 +1,77 @@
// Persisted, app-global preference for how wide the chat column renders.
//
// "standard" keeps the readable centered column (the responsive 3xl/4xl/5xl
// caps live in index.css); "wide" lets the message list and composer fill the
// available width, with message bubbles held to a share of it so prose stays
// legible. Applied as `data-chat-width` on <html>, so the CSS vars in index.css
// swap without any React plumbing. Set from Appearance settings.
const STORAGE_KEY = "omnigent:chat-width";
export const chatWidths = ["standard", "wide"] as const;
export type ChatWidth = (typeof chatWidths)[number];
/** Product default: the centered, readable column. */
export const CHAT_WIDTH_DEFAULT: ChatWidth = "standard";
/** Return whether a string is one of the selectable chat widths. */
export function isChatWidth(value: string | null | undefined): value is ChatWidth {
return value === "standard" || value === "wide";
}
/**
* Normalize a stored chat width to the product default. Unknown values can only
* come from localStorage drift or manual edits.
*/
export function normalizeChatWidth(value: string | null | undefined): ChatWidth {
return isChatWidth(value) ? value : CHAT_WIDTH_DEFAULT;
}
/**
* Read the persisted chat width. Returns "standard" when nothing is stored, on
* a server render (no `window`), or when the stored value is missing/unknown —
* never throws, so a corrupt entry can't break app boot.
*/
export function readChatWidth(): ChatWidth {
if (typeof window === "undefined") return CHAT_WIDTH_DEFAULT;
try {
return normalizeChatWidth(window.localStorage.getItem(STORAGE_KEY));
} catch {
return CHAT_WIDTH_DEFAULT;
}
}
/**
* Persist the chat width. "standard" clears the key (the product default).
* Swallows quota/access errors so a failed write can't break settings.
*/
export function writeChatWidth(value: ChatWidth): void {
if (typeof window === "undefined") return;
try {
const normalized = normalizeChatWidth(value);
if (normalized === CHAT_WIDTH_DEFAULT) {
window.localStorage.removeItem(STORAGE_KEY);
} else {
window.localStorage.setItem(STORAGE_KEY, normalized);
}
} catch {
// localStorage quota or access errors shouldn't break settings.
}
}
/**
* Apply the chat width to the DOM by setting `data-chat-width` on the document
* root. The `[data-chat-width="wide"]` block in index.css re-points the column
* width vars; the default "standard" removes the attribute so the base `:root`
* vars (and their responsive caps) take over. Single source of the DOM
* side-effect, mirroring {@link applyThemePalette}.
*/
export function applyChatWidth(value: ChatWidth): void {
if (typeof document === "undefined") return;
const next = normalizeChatWidth(value);
if (next === CHAT_WIDTH_DEFAULT) {
document.documentElement.removeAttribute("data-chat-width");
return;
}
document.documentElement.setAttribute("data-chat-width", next);
}
+4
View File
@@ -22,6 +22,7 @@ import {
readUiFontSizePx,
} from "./lib/uiFontPreferences";
import { applyThemePalette, readThemePalette } from "./lib/themePalette";
import { applyChatWidth, readChatWidth } from "./lib/chatWidthPreferences";
import { applyCustomTheme, readCustomTheme } from "./lib/customTheme";
import { initChatStore } from "./store/chatStore";
import "katex/dist/katex.min.css";
@@ -78,6 +79,9 @@ if (typeof window !== "undefined") {
applyCustomTheme(readCustomTheme());
applyThemePalette(readThemePalette());
// Apply the saved chat width (data-chat-width on <html>) before first paint.
applyChatWidth(readChatWidth());
// Probe /v1/info BEFORE the first render so the route table knows
// whether to mount accounts routes. The probe is unauthed and the
// failure path resolves to "accounts off" — so even a stalled or
+3 -3
View File
@@ -190,7 +190,7 @@ describe("BubbleView dispatch", () => {
);
const bubble = screen.getByTestId("message-bubble");
expect(bubble).toHaveAttribute("data-role", "user");
expect(bubble).toHaveClass("max-w-[640px]");
expect(bubble).toHaveClass("max-w-[var(--chat-user-bubble-max)]");
expect(bubble).toHaveTextContent("hello there");
});
@@ -285,11 +285,11 @@ describe("BubbleView dispatch", () => {
it("spans the column for a fold-only turn so the row's hairline draws", () => {
// WHY: shrink-wrapped to the ~110px summary row, the trailing hairline
// (a flex-1 span) collapses to zero width and the click target stops
// short of the column. The max-w-3xl cap keeps it aligned with the rule
// short of the column. The bubble cap keeps it aligned with the rule
// under an answered turn.
render(<BubbleView bubble={foldOnlyBubble([toolItem("c4")])} />);
const bubble = screen.getByTestId("message-bubble");
expect(bubble).toHaveClass("max-w-3xl");
expect(bubble).toHaveClass("max-w-[var(--chat-assistant-bubble-max)]");
expect(bubble.firstElementChild).toHaveClass("w-full");
expect(bubble.firstElementChild).not.toHaveClass("w-fit");
});
+6 -4
View File
@@ -345,8 +345,10 @@ export function collectBubbleMarkdown(items: RenderItem[]): string {
.trim();
}
// All chat-column elements must share this width to stay aligned.
const CHAT_COLUMN_WIDTH = "max-w-3xl min-[1921px]:max-w-4xl min-[2561px]:max-w-5xl";
// All chat-column elements must share this width to stay aligned. The cap
// itself (responsive default vs. full-width "wide" mode) lives in the
// --chat-col-max CSS var — see lib/chatWidthPreferences.ts / index.css.
const CHAT_COLUMN_WIDTH = "max-w-[var(--chat-col-max)]";
const TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
const DISPLAY_MATH_RE = /(^|\n)\s*(\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\])/;
@@ -3482,7 +3484,7 @@ function UserBubble({ bubble }: { bubble: Extract<Bubble, { kind: "user" }> }) {
data-testid="message-bubble"
data-role="user"
data-user-message-id={bubble.itemId}
className="max-w-[640px]"
className="max-w-[var(--chat-user-bubble-max)]"
>
{/* w-fit + ml-auto shrink-wrap the row so the author avatar sits
immediately left of the right-aligned bubble (the bubble's own
@@ -3666,7 +3668,7 @@ function AssistantBubble({
from="assistant"
data-testid="message-bubble"
data-role="assistant"
className={isWide ? "max-w-full" : "max-w-3xl"}
className={isWide ? "max-w-full" : "max-w-[var(--chat-assistant-bubble-max)]"}
>
{/* A fold-only bubble takes w-full at the ordinary max-w-3xl cap
rather than shrink-wrapping to the summary row's ~110px, which
+55
View File
@@ -41,6 +41,7 @@ import {
import {
ArchiveRestoreIcon,
AlertTriangleIcon,
AlignCenterIcon,
CheckIcon,
KeyRoundIcon,
LaptopMinimalIcon,
@@ -51,6 +52,7 @@ import {
PanelRightCloseIcon,
PanelRightIcon,
PlusIcon,
StretchHorizontalIcon,
SunIcon,
Trash2Icon,
UserCogIcon,
@@ -137,6 +139,13 @@ import {
writeWorkspacePanelDefault,
type WorkspacePanelDefault,
} from "@/lib/workspacePanelPreferences";
import {
applyChatWidth,
CHAT_WIDTH_DEFAULT,
type ChatWidth,
readChatWidth,
writeChatWidth,
} from "@/lib/chatWidthPreferences";
import { readDefaultBaseBranch, writeDefaultBaseBranch } from "@/lib/baseBranchPreferences";
import {
DEFAULT_HIDE_UNCONFIGURED_HARNESSES,
@@ -285,6 +294,15 @@ const workspacePanelCards: {
{ value: "collapsed", label: "Collapsed", icon: PanelRightCloseIcon },
];
const chatWidthCards: {
value: ChatWidth;
label: string;
icon: typeof AlignCenterIcon;
}[] = [
{ value: "standard", label: "Standard", icon: AlignCenterIcon },
{ value: "wide", label: "Wide", icon: StretchHorizontalIcon },
];
/**
* Checkmark badge pinned to the top-right corner of a selected card. Shared by
* every appearance radiogroup so "selected" reads identically everywhere.
@@ -572,6 +590,37 @@ function WorkspacePanelDefaultControl() {
);
}
/** Chat column width: the readable centered column, or full available width. */
function ChatWidthControl() {
const [value, setValue] = useState(() => readChatWidth());
const labelId = useId();
const choose = useCallback((next: ChatWidth) => {
setValue(next);
writeChatWidth(next);
applyChatWidth(next);
}, []);
return (
<ThemeSubsection
labelId={labelId}
title="Chat width"
helper="Standard keeps a centered, readable column. Wide lets the chat and composer fill the available width."
>
<CardRadioGroup<ChatWidth>
labelledBy={labelId}
value={value}
onSelect={choose}
className="grid grid-cols-2 gap-3"
cardClassName="items-center gap-2 p-4"
items={chatWidthCards.map((card) => ({
value: card.value,
testId: `chat-width-${card.value}`,
body: iconCardBody(card.icon, card.label),
}))}
/>
</ThemeSubsection>
);
}
function ColorThemeControl() {
// Render each chip in the currently-resolved mode so it matches the app now.
const { resolvedTheme } = useTheme();
@@ -846,6 +895,9 @@ function AppearanceSection() {
writeWorkspacePanelDefault(WORKSPACE_PANEL_DEFAULT);
writeChatWidth(CHAT_WIDTH_DEFAULT);
applyChatWidth(CHAT_WIDTH_DEFAULT);
writeHideUnconfiguredHarnesses(DEFAULT_HIDE_UNCONFIGURED_HARNESSES);
applyDesktopUiFontSize(UI_FONT_SIZE_DEFAULT);
@@ -869,6 +921,7 @@ function AppearanceSection() {
"omnigent:ui-theme-palette",
"omnigent:custom-theme",
"omnigent:default-workspace-panel",
"omnigent:chat-width",
"omnigent:hide-unconfigured-harnesses",
]) {
window.localStorage.removeItem(key);
@@ -912,6 +965,8 @@ function AppearanceSection() {
<WorkspacePanelDefaultControl />
<ChatWidthControl />
<HideUnconfiguredHarnessesControl />
<UiFontSizeControl />