fix(chat): widen the conversation gutter and align its edges (MUL-5497) (#6140)

The chat body hugged its pane: a flat px-5 (20px) on every layer, which
reads as cramped once the conversation pane is wider than the floating
window it was tuned for.

The gutter now scales with the CONTAINER — 20px base, 32px past @2xl
(672px), 48px past @4xl (896px) — so the chat tab's resizable pane and
the agent builder column breathe while the 360px floating window keeps
exactly its current spacing. Viewport variants would have been wrong
here: these three surfaces are independent widths inside one window.

Layout also drifted between layers. The message list capped `max-w-4xl`
with its padding INSIDE the cap while the banners and composer put theirs
outside, so past ~936px the text sat 20px narrower than the box below it.
Both now come from one CHAT_GUTTER / CHAT_COLUMN pair — gutter outside the
cap — which is what keeps the edges locked together.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Jiayuan Zhang
2026-07-29 21:42:23 +08:00
committed by GitHub
parent 9e3b661d44
commit beb3e9be65
10 changed files with 188 additions and 38 deletions

View File

@@ -1629,7 +1629,9 @@ function BuilderConversation({
];
return (
<section className="flex min-h-0 flex-col bg-background">
// `@container`: this is one column of the studio's split layout, so the
// shared chat gutter must size against the column, not the viewport.
<section className="flex min-h-0 flex-col bg-background @container">
<header className="flex min-h-14 shrink-0 items-center justify-between gap-4 border-b px-5 py-2.5">
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold">

View File

@@ -223,8 +223,10 @@ export function ChatPage() {
// banner + input. Identical composition to the floating window's body, so a
// brand-new chat (no active session) shows the agent-aware empty state + input.
// No compose-box agent selector — the agent is fixed when the chat starts.
// `@container`: the conversation column's gutter (CHAT_GUTTER) widens with
// THIS pane, which the user resizes independently of the browser window.
const conversation = (
<div className="flex flex-1 flex-col min-h-0">
<div className="flex flex-1 flex-col min-h-0 @container">
{c.currentSession && (
<ChatSessionHeader
session={c.currentSession}

View File

@@ -1,6 +1,8 @@
"use client";
import { Archive } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { CHAT_COLUMN, CHAT_GUTTER } from "./chat-column";
import { useT } from "../../i18n";
// Sibling of OfflineBanner / NoAgentBanner, occupying the same banner slot
@@ -8,14 +10,14 @@ import { useT } from "../../i18n";
// (retired): the input above is disabled and this banner explains that the
// conversation is read-only history — the agent can no longer reply.
//
// Layout (`px-5` outer, `mx-auto max-w-4xl` inner) mirrors its siblings so the
// banner's edges line up with the input on every viewport size.
// Layout comes from the shared CHAT_GUTTER / CHAT_COLUMN pair, same as its
// siblings, so the banner's edges line up with the input at every surface width.
export function ArchivedAgentBanner({ agentName }: { agentName?: string }) {
const { t } = useT("chat");
const name = agentName?.trim() || t(($) => $.offline_banner.fallback_name);
return (
<div className="px-5 mb-1.5">
<div className="mx-auto flex w-full max-w-4xl items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-muted text-muted-foreground ring-1 ring-border">
<div className={cn(CHAT_GUTTER, "mb-1.5")}>
<div className={cn(CHAT_COLUMN, "flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-muted text-muted-foreground ring-1 ring-border")}>
<Archive className="size-3.5 shrink-0" />
<span className="truncate">
{t(($) => $.archived_agent_banner, { name })}

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import enChat from "../../locales/en/chat.json";
import { CHAT_COLUMN, CHAT_GUTTER } from "./chat-column";
import { ChatMessageSkeleton } from "./chat-message-list";
import { NoAgentBanner } from "./no-agent-banner";
import { ArchivedAgentBanner } from "./archived-agent-banner";
import { OfflineBanner } from "./offline-banner";
// Every layer of the chat body has to land on the same left/right edges: the
// message column, the status banner above the composer, and the composer card.
// They drifted once already — the message list capped `max-w-4xl` with its
// padding INSIDE the cap while the composer put the padding outside, so on any
// surface wider than ~936px the text sat 20px narrower than the box below it.
// These tests pin the shared two-layer contract that fixed it.
const TEST_RESOURCES = { en: { chat: enChat } };
function renderChat(ui: React.ReactElement) {
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
{ui}
</I18nProvider>,
);
}
const GUTTER_CLASSES = CHAT_GUTTER.split(" ");
const COLUMN_CLASSES = CHAT_COLUMN.split(" ");
/** Outermost element of a rendered chat-body layer. */
function root(container: HTMLElement): HTMLElement {
const el = container.firstElementChild;
if (!(el instanceof HTMLElement)) throw new Error("layer rendered nothing");
return el;
}
describe("chat column geometry", () => {
it("keeps the gutter a container query, not a viewport one", () => {
// The chat body renders in a resizable split pane, a 360px floating window,
// and the agent builder — all independent widths inside one browser window,
// so a `sm:`/`lg:` variant would widen the floating window's gutter just
// because the page behind it is wide.
for (const cls of GUTTER_CLASSES) {
if (cls.includes(":")) expect(cls).toMatch(/^@/);
}
// Base gutter with no variant, so a host that forgets `@container` degrades
// to the old flat spacing instead of losing its padding entirely.
expect(GUTTER_CLASSES).toContain("px-5");
});
it("puts the gutter OUTSIDE the width cap, never on one element", () => {
// This is the invariant that broke: a single element carrying both means
// the padding eats into the cap, and that layer ends up narrower than its
// siblings once the surface is wider than the cap.
for (const cls of GUTTER_CLASSES) {
expect(COLUMN_CLASSES).not.toContain(cls);
}
expect(COLUMN_CLASSES).toContain("max-w-4xl");
expect(GUTTER_CLASSES.some((c) => c.includes("max-w"))).toBe(false);
});
it.each([
["no-agent banner", <NoAgentBanner key="n" />],
["archived-agent banner", <ArchivedAgentBanner key="a" agentName="Lambda" />],
["offline banner", <OfflineBanner key="o" agentName="Lambda" availability="offline" />],
["unstable banner", <OfflineBanner key="u" agentName="Lambda" availability="unstable" />],
["message skeleton", <ChatMessageSkeleton key="s" />],
])("aligns the %s on the shared gutter + column", (_label, ui) => {
const { container } = renderChat(ui);
const outer = root(container);
const inner = outer.firstElementChild as HTMLElement;
for (const cls of GUTTER_CLASSES) expect(outer).toHaveClass(cls);
for (const cls of COLUMN_CLASSES) expect(inner).toHaveClass(cls);
// The cap belongs to the inner box only — see the test above.
expect(outer.className).not.toContain("max-w-");
});
it("does not double the gutter when the skeleton nests inside the list", () => {
// ChatMessageList's pre-mount frame is already inside the gutter, so it
// renders the skeleton BODY; only the standalone export carries a gutter.
const { container } = renderChat(<ChatMessageSkeleton />);
const gutters = container.querySelectorAll(`.${CSS.escape(GUTTER_CLASSES[0]!)}`);
expect(gutters).toHaveLength(1);
});
});

View File

@@ -0,0 +1,30 @@
/**
* Conversation-column geometry — ONE definition shared by every layer of the
* chat body (message rows, status banners, composer) so their left and right
* edges line up exactly.
*
* The column is two nested boxes, and the nesting order is the point:
*
* <div className={CHAT_GUTTER}> // minimum distance from the surface edges
* <div className={CHAT_COLUMN}> // the reading column itself
*
* Gutter OUTSIDE the cap makes the gutter a floor: it only bites while the
* surface is narrower than the cap, and past that the column parks at
* `max-w-4xl` and centers. The message list used to nest these the other way
* round (`mx-auto max-w-4xl px-5` on a single element), which capped its text at
* 40px narrower than the composer card on any surface wider than ~936px — the
* two edges visibly failed to line up.
*
* The gutter scales with the CONTAINER, never the viewport. These components
* render in a resizable split pane (the chat tab), a 360px floating window, and
* the agent builder — all of which are independent widths inside the same
* browser window, so a `lg:` viewport variant would widen the floating window's
* gutter just because the window behind it is wide. Hosts therefore have to mark
* the chat column `@container`; ChatPage, ChatWindow, and the agent builder do.
* Without that ancestor the `@` variants simply never match and the column keeps
* the base 20px, which is the old behavior rather than a broken layout.
*/
export const CHAT_GUTTER = "px-5 @2xl:px-8 @4xl:px-12";
/** The reading column: centered, capped, full-width below the cap. */
export const CHAT_COLUMN = "mx-auto w-full max-w-4xl";

View File

@@ -19,6 +19,7 @@ import {
} from "../../editor/use-coordinated-uploads";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { ChatAddMenu } from "./chat-add-menu";
import { CHAT_COLUMN, CHAT_GUTTER } from "./chat-column";
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
import { attachmentToDraftUpload, type DraftUpload } from "@multica/core/drafts";
import { createLogger } from "@multica/core/logger";
@@ -555,7 +556,8 @@ export function ChatInput({
// user resizes or expands the window. The wrapper must be a flex
// column for the card below to shrink into that cap instead of
// spilling out of it.
"flex max-h-[50%] min-h-0 flex-col px-5 pb-3 pt-0",
"flex max-h-[50%] min-h-0 flex-col pb-3 pt-0",
CHAT_GUTTER,
// Outer wrapper carries the disabled cursor. Inner card sets
// pointer-events-none, which suppresses hover (and therefore
// any cursor of its own) — splitting the two layers lets hover
@@ -571,7 +573,8 @@ export function ChatInput({
// once, and it keeps the cap finite if a future host ever mounts the
// composer without a definite height (percentage max-height would
// then resolve to none).
"relative mx-auto flex min-h-16 max-h-96 w-full max-w-4xl flex-col rounded-lg border border-surface-border bg-surface pb-9 transition-[border-color,box-shadow] focus-within:border-brand focus-within:ring-2 focus-within:ring-ring/20",
CHAT_COLUMN,
"relative flex min-h-16 max-h-96 flex-col rounded-lg border border-surface-border bg-surface pb-9 transition-[border-color,box-shadow] focus-within:border-brand focus-within:ring-2 focus-within:ring-ring/20",
// Visual + interaction lock when there's no agent. We don't
// toggle ContentEditor's editable mode (Tiptap can't switch
// cleanly post-mount, and the prop has been removed); instead

View File

@@ -34,6 +34,7 @@ import type {
import type { ChatTimelineItem } from "@multica/core/chat";
import { buildTimeline } from "../../common/task-transcript";
import { TaskStatusPill } from "./task-status-pill";
import { CHAT_COLUMN, CHAT_GUTTER } from "./chat-column";
import { formatElapsedMs } from "../lib/format";
import { splitTimeline, extractCopyText } from "../lib/copy-text";
import { useT } from "../../i18n";
@@ -102,7 +103,7 @@ function messageRowKey(message: ChatMessage): string {
function ChatListHeader({ context }: { context?: ChatListContext }) {
const { t } = useT("chat");
return (
<div className="mx-auto w-full max-w-4xl px-5 pt-4">
<div className={cn(CHAT_COLUMN, "pt-4")}>
{context?.isFetchingOlderMessages && (
<div className="text-center text-xs text-muted-foreground">
{t(($) => $.message_list.loading_older)}
@@ -119,7 +120,7 @@ function ChatListFooter({ context }: { context?: ChatListContext }) {
if (!context) return null;
if (!context.showStatusPill || !context.pendingTask) return null;
return (
<div className="mx-auto w-full max-w-4xl px-5 pb-4 space-y-4">
<div className={cn(CHAT_COLUMN, "pb-4 space-y-4")}>
<TaskStatusPill
pendingTask={context.pendingTask}
taskMessages={context.liveTaskMessages ?? []}
@@ -210,11 +211,17 @@ export function ChatMessageList({
ref={setScrollContainerRef}
data-tab-scroll-root
style={fadeStyle}
className="flex-1 overflow-y-auto"
// The gutter lives on the scroll container, so it applies once to the
// whole list — rows, header, footer — and the scrollbar still rides the
// surface edge rather than being inset with the text.
className={cn("flex-1 overflow-y-auto", CHAT_GUTTER)}
>
{/* Already inside the gutter + column, so this pre-mount frame renders the
* skeleton BODY rather than <ChatMessageSkeleton>, which brings its own
* wrapper for use as a standalone sibling of the list. */}
{!scrollContainerEl ? (
<div className="mx-auto w-full max-w-4xl px-5 pt-4 space-y-3">
<ChatMessageSkeleton />
<div className={cn(CHAT_COLUMN, "pt-4")}>
<ChatSkeletonBody />
</div>
) : (
// Chat scrolls inside its own element, so rich blocks must measure
@@ -248,7 +255,7 @@ export function ChatMessageList({
context={listContext}
components={LIST_COMPONENTS}
itemContent={(_, item) => (
<div className="mx-auto w-full max-w-4xl px-5 py-2">
<div className={cn(CHAT_COLUMN, "py-2")}>
<MessageBubble
item={item}
isPending={!!pendingTaskId && item.taskId === pendingTaskId}
@@ -271,20 +278,30 @@ export function ChatMessageList({
*/
export function ChatMessageSkeleton() {
return (
<div className="flex-1 overflow-hidden">
<div className="mx-auto w-full max-w-4xl px-5 py-4 space-y-5">
<div className="space-y-2">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3.5 w-1/2" />
</div>
<div className="flex justify-end">
<Skeleton className="h-8 w-48 rounded-2xl" />
</div>
<div className="space-y-2">
<Skeleton className="h-3.5 w-2/3" />
<Skeleton className="h-3.5 w-5/6" />
<Skeleton className="h-3.5 w-1/3" />
</div>
<div className={cn("flex-1 overflow-hidden", CHAT_GUTTER)}>
<div className={cn(CHAT_COLUMN, "py-4")}>
<ChatSkeletonBody />
</div>
</div>
);
}
// The rows themselves, so the list's pre-mount frame can drop them straight
// into the gutter + column it already established.
function ChatSkeletonBody() {
return (
<div className="space-y-5">
<div className="space-y-2">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3.5 w-1/2" />
</div>
<div className="flex justify-end">
<Skeleton className="h-8 w-48 rounded-2xl" />
</div>
<div className="space-y-2">
<Skeleton className="h-3.5 w-2/3" />
<Skeleton className="h-3.5 w-5/6" />
<Skeleton className="h-3.5 w-1/3" />
</div>
</div>
);

View File

@@ -726,7 +726,10 @@ export function ChatWindow() {
const isVisible = isOpen && (isExpanded || boundsReady);
const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col overflow-hidden rounded-xl bg-surface-raised shadow-[var(--floating-shadow)] ring-1 ring-surface-border";
// `@container`: the window is user-resizable from 360px to 90% of the
// viewport, so the chat body's gutter (CHAT_GUTTER) has to key off the
// window's own width, not the page behind it.
const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col overflow-hidden rounded-xl bg-surface-raised shadow-[var(--floating-shadow)] ring-1 ring-surface-border @container";
const containerStyle: React.CSSProperties = {
transformOrigin: "bottom right",
pointerEvents: isOpen ? "auto" : "none",

View File

@@ -1,6 +1,8 @@
"use client";
import { Bot } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { CHAT_COLUMN, CHAT_GUTTER } from "./chat-column";
import { useT } from "../../i18n";
// Sibling of ChatInput, occupying the same banner slot as OfflineBanner.
@@ -13,14 +15,14 @@ import { useT } from "../../i18n";
// is more disruptive than just stating the prerequisite. Users who want
// to act go to Agents on their own.
//
// Layout (`px-5` outer, `mx-auto max-w-4xl` inner) mirrors OfflineBanner
// and ChatInput so the banner's edges line up with the input on every
// viewport size.
// Layout comes from the shared CHAT_GUTTER / CHAT_COLUMN pair, same as
// OfflineBanner and ChatInput, so the banner's edges line up with the input at
// every surface width.
export function NoAgentBanner() {
const { t } = useT("chat");
return (
<div className="px-5 mb-1.5">
<div className="mx-auto flex w-full max-w-4xl items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-muted text-muted-foreground ring-1 ring-border">
<div className={cn(CHAT_GUTTER, "mb-1.5")}>
<div className={cn(CHAT_COLUMN, "flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-muted text-muted-foreground ring-1 ring-border")}>
<Bot className="size-3.5 shrink-0" />
<span className="truncate">{t(($) => $.no_agent_banner)}</span>
</div>

View File

@@ -1,7 +1,9 @@
"use client";
import { AlertCircle, WifiOff } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import type { AgentAvailability } from "@multica/core/agents";
import { CHAT_COLUMN, CHAT_GUTTER } from "./chat-column";
import { useT } from "../../i18n";
interface Props {
@@ -26,8 +28,8 @@ export function OfflineBanner({ agentName, availability }: Props) {
const name = agentName?.trim() || t(($) => $.offline_banner.fallback_name);
if (availability === "unstable") {
return (
<div className="px-5 mb-1.5">
<div className="mx-auto flex w-full max-w-4xl items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-amber-50 dark:bg-amber-950/40 text-amber-900 dark:text-amber-200 ring-1 ring-amber-200/60 dark:ring-amber-900/40">
<div className={cn(CHAT_GUTTER, "mb-1.5")}>
<div className={cn(CHAT_COLUMN, "flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-amber-50 dark:bg-amber-950/40 text-amber-900 dark:text-amber-200 ring-1 ring-amber-200/60 dark:ring-amber-900/40")}>
<AlertCircle className="size-3.5 shrink-0" />
<span className="truncate">
{t(($) => $.offline_banner.unstable, { name })}
@@ -37,8 +39,8 @@ export function OfflineBanner({ agentName, availability }: Props) {
);
}
return (
<div className="px-5 mb-1.5">
<div className="mx-auto flex w-full max-w-4xl items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-muted text-muted-foreground ring-1 ring-border">
<div className={cn(CHAT_GUTTER, "mb-1.5")}>
<div className={cn(CHAT_COLUMN, "flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-muted text-muted-foreground ring-1 ring-border")}>
<WifiOff className="size-3.5 shrink-0" />
<span className="truncate">
{t(($) => $.offline_banner.offline, { name })}