mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
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:
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user