diff --git a/packages/views/agents/components/agent-creation-studio.tsx b/packages/views/agents/components/agent-creation-studio.tsx index abeb9c819e..6d27ae1e39 100644 --- a/packages/views/agents/components/agent-creation-studio.tsx +++ b/packages/views/agents/components/agent-creation-studio.tsx @@ -1629,7 +1629,9 @@ function BuilderConversation({ ]; return ( -
+ // `@container`: this is one column of the studio's split layout, so the + // shared chat gutter must size against the column, not the viewport. +

diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index 6a4d93b1c8..31df8edd65 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -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 = ( -
+
{c.currentSession && ( $.offline_banner.fallback_name); return ( -
-
+
+
{t(($) => $.archived_agent_banner, { name })} diff --git a/packages/views/chat/components/chat-column.test.tsx b/packages/views/chat/components/chat-column.test.tsx new file mode 100644 index 0000000000..af7f1481a9 --- /dev/null +++ b/packages/views/chat/components/chat-column.test.tsx @@ -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( + + {ui} + , + ); +} + +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", ], + ["archived-agent banner", ], + ["offline banner", ], + ["unstable banner", ], + ["message skeleton", ], + ])("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(); + const gutters = container.querySelectorAll(`.${CSS.escape(GUTTER_CLASSES[0]!)}`); + expect(gutters).toHaveLength(1); + }); +}); diff --git a/packages/views/chat/components/chat-column.ts b/packages/views/chat/components/chat-column.ts new file mode 100644 index 0000000000..675c33d369 --- /dev/null +++ b/packages/views/chat/components/chat-column.ts @@ -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: + * + *
// minimum distance from the surface edges + *
// 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"; diff --git a/packages/views/chat/components/chat-input.tsx b/packages/views/chat/components/chat-input.tsx index 71a8b86bf9..ea382e2a91 100644 --- a/packages/views/chat/components/chat-input.tsx +++ b/packages/views/chat/components/chat-input.tsx @@ -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 diff --git a/packages/views/chat/components/chat-message-list.tsx b/packages/views/chat/components/chat-message-list.tsx index 42556dfe22..ef357b0836 100644 --- a/packages/views/chat/components/chat-message-list.tsx +++ b/packages/views/chat/components/chat-message-list.tsx @@ -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 ( -
+
{context?.isFetchingOlderMessages && (
{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 ( -
+
+ {/* Already inside the gutter + column, so this pre-mount frame renders the + * skeleton BODY rather than , which brings its own + * wrapper for use as a standalone sibling of the list. */} {!scrollContainerEl ? ( -
- +
+
) : ( // 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) => ( -
+
-
-
- - -
-
- -
-
- - - -
+
+
+ +
+
+ ); +} + +// The rows themselves, so the list's pre-mount frame can drop them straight +// into the gutter + column it already established. +function ChatSkeletonBody() { + return ( +
+
+ + +
+
+ +
+
+ + +
); diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx index 2425b9d5fe..a625dd477d 100644 --- a/packages/views/chat/components/chat-window.tsx +++ b/packages/views/chat/components/chat-window.tsx @@ -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", diff --git a/packages/views/chat/components/no-agent-banner.tsx b/packages/views/chat/components/no-agent-banner.tsx index 14d42d6367..162aef833b 100644 --- a/packages/views/chat/components/no-agent-banner.tsx +++ b/packages/views/chat/components/no-agent-banner.tsx @@ -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 ( -
-
+
+
{t(($) => $.no_agent_banner)}
diff --git a/packages/views/chat/components/offline-banner.tsx b/packages/views/chat/components/offline-banner.tsx index d4208c04c8..3f2e859ab4 100644 --- a/packages/views/chat/components/offline-banner.tsx +++ b/packages/views/chat/components/offline-banner.tsx @@ -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 ( -
-
+
+
{t(($) => $.offline_banner.unstable, { name })} @@ -37,8 +39,8 @@ export function OfflineBanner({ agentName, availability }: Props) { ); } return ( -
-
+
+
{t(($) => $.offline_banner.offline, { name })}