this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor chat composer into sticky floating card component (#11)

### TL;DR

Refactored the chat layout to use a sticky, absolutely-positioned composer that overlays the message list, and introduced a reusable `Card` component.

### What changed?

- Introduced a new `ChatComposer` component that wraps `ChatForm` inside a `Card`, positioned absolutely at the bottom of the chat view. `ChatForm` no longer accepts a `className` prop and has a more compact, borderless textarea style.
- Added a `Card` UI component with sub-components (`CardHeader`, `CardTitle`, `CardDescription`, `CardAction`, `CardContent`, `CardFooter`) supporting `default` and `sm` size variants.
- `ChatMessages` now wraps its content in a `Container` with bottom padding to prevent messages from being hidden behind the fixed composer.
- The `Chat` component and the chats index route now use `ChatComposer` instead of `ChatForm` directly, and the layout uses `relative`/`absolute` positioning to anchor the composer to the bottom.
- `Container` now renders as a flex column by default.
- The `SidebarInset` in the chats route layout no longer wraps content in a `Container`, allowing each route to manage its own layout and sizing. The inset height is set to `calc(100dvh - 1rem)`.
- The send button becomes full-width on mobile, and the controls row wraps to a column on smaller screens.

### How to test?

1. Open the chat list page (`/chats`) and verify the composer is pinned to the bottom with the heading and mascot centered above it.
2. Open an existing chat and confirm messages scroll independently while the composer remains fixed at the bottom.
3. Resize the browser to a mobile viewport and confirm the send button spans full width and the controls stack vertically.
4. Verify the `Card` component renders correctly with both `default` and `sm` sizes.

### Why make this change?

The previous layout pushed the chat form inline within the page flow, causing inconsistent spacing and scroll behavior. Pinning the composer absolutely at the bottom provides a more conventional chat UI where the input is always visible and the message list scrolls freely beneath it.

authored by

James Blair and committed by
GitHub
f3aa2cdb c31e3412

+211 -76
+31
apps/web/src/components/chat-composer.tsx
··· 1 + import type { CharacterId, ChatModelId } from "#/lib/types"; 2 + import { ChatForm } from "./chat-form"; 3 + import { Container } from "./container"; 4 + import { Card, CardContent } from "./ui/card"; 5 + 6 + interface ChatComposerProps { 7 + chatId?: string; 8 + characterId?: CharacterId; 9 + modelId?: ChatModelId; 10 + } 11 + 12 + export function ChatComposer({ 13 + chatId, 14 + characterId, 15 + modelId, 16 + }: ChatComposerProps) { 17 + return ( 18 + <Container size="sm" className="absolute inset-x-0 bottom-0 z-20 pb-4"> 19 + <div className="absolute inset-x-0 bottom-0 h-16 bg-background" /> 20 + <Card size="sm" className="relative"> 21 + <CardContent> 22 + <ChatForm 23 + chatId={chatId} 24 + characterId={characterId} 25 + modelId={modelId} 26 + /> 27 + </CardContent> 28 + </Card> 29 + </Container> 30 + ); 31 + }
+9 -6
apps/web/src/components/chat-form.tsx
··· 17 17 chatId?: string; 18 18 characterId?: CharacterId; 19 19 modelId?: ChatModelId; 20 - className?: string; 21 20 } 22 21 23 22 export function ChatForm({ 24 23 chatId, 25 24 characterId: chatCharacterId, 26 25 modelId: chatModelId, 27 - className, 28 26 }: ChatFormProps) { 29 27 const { isLoaded, isSignedIn } = useAuth(); 30 28 const { data: characters = [] } = useCharacters(); ··· 108 106 } 109 107 110 108 return ( 111 - <form onSubmit={handleSubmit} className={className}> 109 + <form onSubmit={handleSubmit}> 112 110 <Stack gap="sm"> 113 111 <Textarea 114 112 autoFocus 115 113 placeholder="Write your message here..." 116 - className="wrap-anywhere max-h-64 min-h-25 focus-visible:border-transparent focus-visible:ring-0" 114 + className="wrap-anywhere max-h-32 min-h-16 rounded-none border-none bg-transparent p-0 focus-visible:ring-0" 117 115 value={text} 118 116 disabled={isAuthUnavailable} 119 117 onChange={(event) => setText(event.currentTarget.value)} ··· 124 122 } 125 123 }} 126 124 /> 127 - <Row justify="between" items="end"> 125 + <Row gap="sm" justify="between" items="end" className="max-md:flex-col"> 128 126 <Row gap="sm" className="flex-wrap"> 129 127 <CharacterSelect 130 128 value={selectedCharacterId} ··· 145 143 disabled={isModelDisabled} 146 144 /> 147 145 </Row> 148 - <Button type="submit" size="lg" disabled={isDisabled}> 146 + <Button 147 + type="submit" 148 + size="lg" 149 + disabled={isDisabled} 150 + className="max-md:w-full" 151 + > 149 152 Send 150 153 <SendIcon /> 151 154 </Button>
+1 -1
apps/web/src/components/chat-message-bubble.tsx
··· 4 4 import { TypingIndicator } from "./typing-indicator"; 5 5 6 6 const chatMessageBubble = cva( 7 - "rounded-2xl px-4 py-3 text-sm leading-relaxed wrap-anywhere max-w-[80%]", 7 + "max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed wrap-anywhere", 8 8 { 9 9 variants: { 10 10 author: {
+38 -37
apps/web/src/components/chat-messages.tsx
··· 3 3 import { useRegenerateLastMessage } from "#/hooks/use-regenerate-last-message"; 4 4 import type { ChatMessage as ChatMessageType } from "#/lib/types"; 5 5 import { ChatMessage } from "./chat-message"; 6 + import { Container } from "./container"; 6 7 import { Stack } from "./layout"; 7 8 8 9 interface ChatMessageProps { ··· 100 101 }, [messages]); 101 102 102 103 return ( 103 - <Stack 104 - ref={containerRef} 105 - gap="xs" 106 - className="no-scrollbar flex-1 overflow-y-auto" 107 - > 108 - {messages.map((message) => ( 109 - <ChatMessage 110 - key={message.id} 111 - author={message.role} 112 - canEdit={message.id === lastEditableUserMessage?.id} 113 - canRegenerate={ 114 - message.id === messages.at(-1)?.id && 115 - message.role === "assistant" && 116 - message.text.trim().length > 0 117 - } 118 - isEditing={message.id === editingMessageId} 119 - isPending={ 120 - editLastMessage.isPending || regenerateLastMessage.isPending 121 - } 122 - isRegenerating={regenerateLastMessage.isPending} 123 - onCancelEdit={() => { 124 - setEditingMessageId(null); 125 - }} 126 - onEdit={() => { 127 - setEditingMessageId(message.id); 128 - }} 129 - onSaveEdit={async (text) => { 130 - await editLastMessage.mutateAsync({ chatId, text }); 131 - setEditingMessageId(null); 132 - }} 133 - onRegenerate={() => { 134 - regenerateLastMessage.mutate({ chatId }); 135 - }} 136 - text={message.text} 137 - /> 138 - ))} 139 - </Stack> 104 + <div ref={containerRef} className="no-scrollbar flex-1 overflow-y-auto"> 105 + <Container size="sm" className="pt-4 pb-54 md:pb-44"> 106 + <Stack gap="xs"> 107 + {messages.map((message) => ( 108 + <ChatMessage 109 + key={message.id} 110 + author={message.role} 111 + canEdit={message.id === lastEditableUserMessage?.id} 112 + canRegenerate={ 113 + message.id === messages.at(-1)?.id && 114 + message.role === "assistant" && 115 + message.text.trim().length > 0 116 + } 117 + isEditing={message.id === editingMessageId} 118 + isPending={ 119 + editLastMessage.isPending || regenerateLastMessage.isPending 120 + } 121 + isRegenerating={regenerateLastMessage.isPending} 122 + onCancelEdit={() => { 123 + setEditingMessageId(null); 124 + }} 125 + onEdit={() => { 126 + setEditingMessageId(message.id); 127 + }} 128 + onSaveEdit={async (text) => { 129 + await editLastMessage.mutateAsync({ chatId, text }); 130 + setEditingMessageId(null); 131 + }} 132 + onRegenerate={() => { 133 + regenerateLastMessage.mutate({ chatId }); 134 + }} 135 + text={message.text} 136 + /> 137 + ))} 138 + </Stack> 139 + </Container> 140 + </div> 140 141 ); 141 142 }
+3 -3
apps/web/src/components/chat.tsx
··· 1 1 import { useChat } from "#/hooks/use-chat"; 2 - import { ChatForm } from "./chat-form"; 2 + import { ChatComposer } from "./chat-composer"; 3 3 import { ChatMessages } from "./chat-messages"; 4 4 import { Stack } from "./layout"; 5 5 ··· 11 11 const { chat, messages } = useChat({ id: chatId }); 12 12 13 13 return ( 14 - <Stack className="flex-1 py-4"> 14 + <Stack className="relative min-h-0 flex-1"> 15 15 <ChatMessages chatId={chatId} messages={messages} /> 16 - <ChatForm 16 + <ChatComposer 17 17 chatId={chatId} 18 18 characterId={chat?.characterId} 19 19 modelId={chat?.modelId}
+1 -1
apps/web/src/components/container.tsx
··· 1 1 import { cva, type VariantProps } from "class-variance-authority"; 2 2 import { cn } from "#/lib/utils"; 3 3 4 - const containerVariants = cva("mx-auto w-full px-4", { 4 + const containerVariants = cva("flex flex-col mx-auto w-full px-4", { 5 5 variants: { 6 6 size: { 7 7 sm: "max-w-2xl",
+100
apps/web/src/components/ui/card.tsx
··· 1 + import type * as React from "react"; 2 + 3 + import { cn } from "#/lib/utils"; 4 + 5 + function Card({ 6 + className, 7 + size = "default", 8 + ...props 9 + }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { 10 + return ( 11 + <div 12 + data-slot="card" 13 + data-size={size} 14 + className={cn( 15 + "group/card flex flex-col gap-6 overflow-hidden rounded-4xl bg-card py-6 text-card-foreground text-sm shadow-md ring-1 ring-foreground/5 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 dark:ring-foreground/10 *:[img:first-child]:rounded-t-4xl *:[img:last-child]:rounded-b-4xl", 16 + className, 17 + )} 18 + {...props} 19 + /> 20 + ); 21 + } 22 + 23 + function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 24 + return ( 25 + <div 26 + data-slot="card-header" 27 + className={cn( 28 + "group/card-header @container/card-header grid auto-rows-min items-start gap-1.5 rounded-t-4xl px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] group-data-[size=sm]/card:px-4 [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4", 29 + className, 30 + )} 31 + {...props} 32 + /> 33 + ); 34 + } 35 + 36 + function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 37 + return ( 38 + <div 39 + data-slot="card-title" 40 + className={cn("font-medium text-base", className)} 41 + {...props} 42 + /> 43 + ); 44 + } 45 + 46 + function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 47 + return ( 48 + <div 49 + data-slot="card-description" 50 + className={cn("text-muted-foreground text-sm", className)} 51 + {...props} 52 + /> 53 + ); 54 + } 55 + 56 + function CardAction({ className, ...props }: React.ComponentProps<"div">) { 57 + return ( 58 + <div 59 + data-slot="card-action" 60 + className={cn( 61 + "col-start-2 row-span-2 row-start-1 self-start justify-self-end", 62 + className, 63 + )} 64 + {...props} 65 + /> 66 + ); 67 + } 68 + 69 + function CardContent({ className, ...props }: React.ComponentProps<"div">) { 70 + return ( 71 + <div 72 + data-slot="card-content" 73 + className={cn("px-6 group-data-[size=sm]/card:px-4", className)} 74 + {...props} 75 + /> 76 + ); 77 + } 78 + 79 + function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 80 + return ( 81 + <div 82 + data-slot="card-footer" 83 + className={cn( 84 + "flex items-center rounded-b-4xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4", 85 + className, 86 + )} 87 + {...props} 88 + /> 89 + ); 90 + } 91 + 92 + export { 93 + Card, 94 + CardAction, 95 + CardContent, 96 + CardDescription, 97 + CardFooter, 98 + CardHeader, 99 + CardTitle, 100 + };
+25 -22
apps/web/src/routes/chats.index.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 import { Attribution } from "#/components/attribution"; 3 - import { ChatForm } from "#/components/chat-form"; 3 + import { ChatComposer } from "#/components/chat-composer"; 4 + import { Container } from "#/components/container"; 4 5 import { Heading } from "#/components/heading"; 5 6 import { Stack } from "#/components/layout"; 6 7 ··· 10 11 11 12 function RouteComponent() { 12 13 return ( 13 - <Stack justify="between" className="flex-1 py-4"> 14 - <Stack justify="center" className="flex-1"> 15 - <Heading>What's on your mind?</Heading> 14 + <Stack className="relative min-h-0 flex-1"> 15 + <Container size="sm" className="h-full pt-4 pb-52 md:pb-40"> 16 + <Stack justify="center" className="flex-1"> 17 + <Heading>What's on your mind?</Heading> 16 18 17 - <figure className="relative"> 18 - <img 19 - src="/kitsune.gif" 20 - alt="Fox girl" 21 - width="318" 22 - height="292" 23 - className="h-36 w-auto" 24 - /> 25 - <figcaption className="absolute top-full pt-2"> 26 - <Attribution 27 - title="RoopyRoo!" 28 - artist="Doosio" 29 - license="CC BY-NC-ND 3.0" 30 - href="https://www.deviantart.com/doosio/art/RoopyRoo-CM-887196542" 19 + <figure className="relative"> 20 + <img 21 + src="/kitsune.gif" 22 + alt="Fox girl" 23 + width="318" 24 + height="292" 25 + className="h-36 w-auto" 31 26 /> 32 - </figcaption> 33 - </figure> 34 - </Stack> 35 - <ChatForm /> 27 + <figcaption className="absolute top-full pt-2"> 28 + <Attribution 29 + title="RoopyRoo!" 30 + artist="Doosio" 31 + license="CC BY-NC-ND 3.0" 32 + href="https://www.deviantart.com/doosio/art/RoopyRoo-CM-887196542" 33 + /> 34 + </figcaption> 35 + </figure> 36 + </Stack> 37 + </Container> 38 + <ChatComposer /> 36 39 </Stack> 37 40 ); 38 41 }
+3 -6
apps/web/src/routes/chats.tsx
··· 1 1 import { createFileRoute, Outlet } from "@tanstack/react-router"; 2 2 import { ChatSidebar } from "#/components/chat-sidebar"; 3 - import { Container } from "#/components/container"; 4 3 import { SidebarInset, SidebarProvider } from "#/components/ui/sidebar"; 5 4 6 5 export const Route = createFileRoute("/chats")({ ··· 9 8 10 9 function RouteComponent() { 11 10 return ( 12 - <SidebarProvider className="h-dvh"> 11 + <SidebarProvider> 13 12 <ChatSidebar /> 14 - <SidebarInset> 15 - <Container size="sm" className="flex h-full"> 16 - <Outlet /> 17 - </Container> 13 + <SidebarInset className="h-[calc(100dvh-1rem)]"> 14 + <Outlet /> 18 15 </SidebarInset> 19 16 </SidebarProvider> 20 17 );