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 with container layout

+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 );