mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
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:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
87
packages/views/chat/components/chat-column.test.tsx
Normal file
87
packages/views/chat/components/chat-column.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
30
packages/views/chat/components/chat-column.ts
Normal file
30
packages/views/chat/components/chat-column.ts
Normal 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";
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
Reference in New Issue
Block a user