Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3490c5be49 |
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user