feat(site/src/pages/AgentsPage): add full-width chat layout toggle (#24307)

Adds a user preference to remove the `max-w-3xl` constraint on agent
chat messages, letting the content fill the available viewport width.

The toggle lives in **Agents > Settings > Behavior** under "Chat Layout"
and persists via `localStorage` (`agents.chat-full-width`). A
`useSyncExternalStore` hook (`useChatFullWidth`) provides same-tab
reactivity so flipping the toggle updates all mounted consumers
immediately — the chat timeline, chat input, the Suspense skeleton, and
the in-page loading view.

This was requested by a customer and is an individual user setting, so
it seems fine to add.
This commit is contained in:
Kyle Carberry
2026-04-13 13:00:39 -04:00
committed by GitHub
parent e0902e3c27
commit a414d37165
7 changed files with 117 additions and 7 deletions
@@ -45,6 +45,7 @@ import { RightPanel } from "./components/RightPanel/RightPanel";
import { SidebarTabView } from "./components/Sidebar/SidebarTabView";
import { TerminalPanel } from "./components/TerminalPanel";
import { ChatWorkspaceContext } from "./context/ChatWorkspaceContext";
import { chatWidthClass, useChatFullWidth } from "./hooks/useChatFullWidth";
import type { ChatDetailError } from "./utils/usageLimitMessage";
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
@@ -552,6 +553,7 @@ export const AgentChatPageLoadingView: FC<AgentChatPageLoadingViewProps> = ({
onToggleSidebarCollapsed,
showRightPanel,
}) => {
const [chatFullWidth] = useChatFullWidth();
return (
<div
className={cn(
@@ -584,7 +586,12 @@ export const AgentChatPageLoadingView: FC<AgentChatPageLoadingViewProps> = ({
/>
<div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<div className="px-4">
<div className="mx-auto w-full max-w-3xl py-6">
<div
className={cn(
"mx-auto w-full py-6",
chatWidthClass(chatFullWidth),
)}
>
<ChatConversationSkeleton />
</div>
</div>
@@ -1,5 +1,6 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ChatFullWidthSettings } from "./components/ChatFullWidthSettings";
import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings";
import { RetentionPeriodSettings } from "./components/RetentionPeriodSettings";
import { SectionHeader } from "./components/SectionHeader";
@@ -131,6 +132,9 @@ export const AgentSettingsBehaviorPageView: FC<
isAnyPromptSaving={isAnyPromptSaving}
/>
<hr className="my-5 border-0 border-t border-solid border-border" />
<ChatFullWidthSettings />
<hr className="my-5 border-0 border-t border-solid border-border" />
<UserCompactionThresholdSettings
modelConfigs={modelConfigsData ?? []}
@@ -51,6 +51,7 @@ import {
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import { isMobileViewport } from "#/utils/mobile";
import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
import { useOverflowCount } from "../hooks/useOverflowCount";
import { useSpeechRecognition } from "../hooks/useSpeechRecognition";
import { formatProviderLabel } from "../utils/modelOptions";
@@ -286,6 +287,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
onMCPAuthComplete,
attachedWorkspace,
}) => {
const [chatFullWidth] = useChatFullWidth();
const internalRef = useRef<ChatMessageInputRef>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewText, setPreviewText] = useState<string | null>(null);
@@ -612,7 +614,8 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
const content = (
<div
className={cn(
"mx-auto w-full max-w-3xl pb-0 sm:pb-4",
"mx-auto w-full pb-0 sm:pb-4",
chatWidthClass(chatFullWidth),
isEditingHistoryMessage && "pt-1",
)}
>
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { cn } from "#/utils/cn";
import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
/** localStorage keys shared with the agents panel components. */
const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open";
@@ -126,9 +127,11 @@ export const RightPanelSkeleton: FC = () => (
* the real AgentChatInput so the transition from Suspense fallback to
* the loaded component doesn't cause a vertical layout shift.
*/
const ChatInputSkeleton: FC = () => (
const ChatInputSkeleton: FC<{ fullWidth: boolean }> = ({ fullWidth }) => (
<div className="shrink-0 overflow-y-auto px-4 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<div className="mx-auto w-full max-w-3xl pb-0 sm:pb-4">
<div
className={cn("mx-auto w-full pb-0 sm:pb-4", chatWidthClass(fullWidth))}
>
<div className="rounded-2xl border border-border-default/80 bg-surface-secondary/45 p-1 shadow-sm">
<div className="min-h-[60px] sm:min-h-24 px-3 py-2" />
<div className="flex items-center justify-between gap-2 px-2.5 pb-1.5">
@@ -147,6 +150,7 @@ const ChatInputSkeleton: FC = () => (
*/
export const AgentChatPageSkeleton: FC = () => {
const rightPanel = getRightPanelState();
const [chatFullWidth] = useChatFullWidth();
return (
<div
@@ -165,12 +169,17 @@ export const AgentChatPageSkeleton: FC = () => {
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="px-4">
<div className="mx-auto w-full max-w-3xl py-6">
<div
className={cn(
"mx-auto w-full py-6",
chatWidthClass(chatFullWidth),
)}
>
<ChatConversationSkeleton />
</div>
</div>
</div>
<ChatInputSkeleton />
<ChatInputSkeleton fullWidth={chatFullWidth} />
</div>
{rightPanel.open && (
<div
@@ -0,0 +1,26 @@
import type { FC } from "react";
import { Switch } from "#/components/Switch/Switch";
import { useChatFullWidth } from "../hooks/useChatFullWidth";
export const ChatFullWidthSettings: FC = () => {
const [enabled, setEnabled] = useChatFullWidth();
return (
<div className="space-y-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Chat Layout
</h3>
<div className="flex items-center justify-between gap-4">
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Use full-width layout for agent chat messages, removing the default
max-width constraint.
</p>
<Switch
checked={enabled}
onCheckedChange={(checked) => setEnabled(Boolean(checked))}
aria-label="Full-width chat"
/>
</div>
</div>
);
};
@@ -2,6 +2,8 @@ import { type FC, Profiler, type ReactNode, useEffect } from "react";
import { toast } from "sonner";
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
import { cn } from "#/utils/cn";
import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
import { useFileAttachments } from "../hooks/useFileAttachments";
import type { ChatDetailError } from "../utils/usageLimitMessage";
import {
@@ -60,6 +62,7 @@ export const ChatPageTimeline: FC<ChatPageTimelineProps> = ({
urlTransform,
mcpServers,
}) => {
const [chatFullWidth] = useChatFullWidth();
const messagesByID = useChatSelector(store, selectMessagesByID);
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
@@ -85,7 +88,10 @@ export const ChatPageTimeline: FC<ChatPageTimelineProps> = ({
<Profiler id="AgentChat" onRender={onRenderProfiler}>
<div
data-testid="chat-timeline-wrapper"
className="mx-auto flex w-full max-w-3xl flex-col gap-2 py-6"
className={cn(
"mx-auto flex w-full flex-col gap-2 py-6",
chatWidthClass(chatFullWidth),
)}
>
{/* VNC sessions for completed agents may already be
terminated, so inline desktop previews are disabled
@@ -0,0 +1,55 @@
import { useSyncExternalStore } from "react";
const KEY = "agents.chat-full-width";
// In-tab subscribers. The native "storage" event only fires
// cross-tab, so we maintain our own listener set for same-tab
// reactivity when the toggle is flipped in settings.
const listeners = new Set<() => void>();
function subscribe(callback: () => void): () => void {
listeners.add(callback);
// Cross-tab changes via the native storage event.
const onStorage = (e: StorageEvent) => {
if (e.key === KEY) {
callback();
}
};
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(callback);
window.removeEventListener("storage", onStorage);
};
}
function getSnapshot(): boolean {
return localStorage.getItem(KEY) === "true";
}
/**
* Returns the Tailwind max-width class for the chat layout
* based on whether full-width mode is enabled.
*/
export function chatWidthClass(fullWidth: boolean): string {
return fullWidth ? "max-w-full" : "max-w-3xl";
}
/**
* Reactive hook for the chat full-width preference. All
* consumers re-render when the value changes — no page reload
* required.
*/
export function useChatFullWidth(): [boolean, (v: boolean) => void] {
const enabled = useSyncExternalStore(subscribe, getSnapshot);
const setEnabled = (value: boolean) => {
localStorage.setItem(KEY, String(value));
for (const fn of listeners) {
fn();
}
};
return [enabled, setEnabled];
}