返回 JoyAI-Echo
Composer.tsx
1 import { useCallback, useEffect, useRef, useState } from "react";
2 import { ArrowUp } from "lucide-react";
3
4 import { Button } from "@/components/ui/button";
5 import { cn } from "@/lib/utils";
6
7 interface ComposerProps {
8 onSend: (content: string) => void;
9 disabled?: boolean;
10 placeholder?: string;
11 /** Visually collapse the outer padding when embedded inside a welcome screen. */
12 compact?: boolean;
13 }
14
15 /**
16 * Rounded, shadowed composer with an embedded send button — modeled after the
17 * agent-chat-ui input: a single surface that looks like one interactive unit
18 * rather than a textarea + button pair.
19 */
20 export function Composer({
21 onSend,
22 disabled,
23 placeholder = "Type your message…",
24 compact = false,
25 }: ComposerProps) {
26 const [value, setValue] = useState("");
27 const textareaRef = useRef<HTMLTextAreaElement>(null);
28
29 // Autofocus on mount — coming back to a chat, switching sessions, or
30 // opening the welcome screen should always land the caret in the box.
31 useEffect(() => {
32 if (disabled) return;
33 const el = textareaRef.current;
34 if (!el) return;
35 // Defer so layout settles first (important during enter animations).
36 const id = requestAnimationFrame(() => el.focus());
37 return () => cancelAnimationFrame(id);
38 }, [disabled]);
39
40 const submit = useCallback(() => {
41 const trimmed = value.trim();
42 if (!trimmed || disabled) return;
43 onSend(trimmed);
44 setValue("");
45 requestAnimationFrame(() => {
46 const el = textareaRef.current;
47 if (el) {
48 el.style.height = "auto";
49 el.focus();
50 }
51 });
52 }, [disabled, onSend, value]);
53
54 const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
55 if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
56 e.preventDefault();
57 submit();
58 }
59 };
60
61 const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
62 const el = e.currentTarget;
63 el.style.height = "auto";
64 el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
65 };
66
67 return (
68 <form
69 onSubmit={(e) => {
70 e.preventDefault();
71 submit();
72 }}
73 className={cn(
74 "w-full",
75 compact ? "px-0" : "bg-background/95 px-4 pb-4 pt-2 backdrop-blur",
76 )}
77 >
78 <div
79 className={cn(
80 "relative mx-auto flex w-full max-w-[64rem] flex-col overflow-hidden rounded-3xl",
81 "border bg-muted/60 shadow-sm transition-all duration-200",
82 "focus-within:bg-muted focus-within:shadow-md focus-within:ring-1 focus-within:ring-foreground/10",
83 disabled && "opacity-60",
84 )}
85 >
86 <textarea
87 ref={textareaRef}
88 value={value}
89 onChange={(e) => setValue(e.target.value)}
90 onInput={onInput}
91 onKeyDown={onKeyDown}
92 rows={1}
93 placeholder={placeholder}
94 disabled={disabled}
95 aria-label="Message input"
96 className={cn(
97 "min-h-[56px] w-full resize-none bg-transparent px-5 pt-4 pb-2 text-sm",
98 "placeholder:text-muted-foreground",
99 "focus:outline-none focus-visible:outline-none",
100 "disabled:cursor-not-allowed",
101 )}
102 />
103 <div className="flex items-center justify-between gap-2 px-3 pb-2">
104 <span className="hidden select-none text-[11px] text-muted-foreground/70 sm:inline">
105 Enter to send · Shift+Enter for newline
106 </span>
107 <span className="sm:hidden" aria-hidden />
108 <Button
109 type="submit"
110 size="icon"
111 disabled={disabled || !value.trim()}
112 aria-label="Send message"
113 className={cn(
114 "h-9 w-9 rounded-full shadow-sm transition-transform",
115 value.trim() && !disabled && "hover:scale-[1.03] active:scale-95",
116 )}
117 >
118 <ArrowUp className="h-4 w-4" />
119 </Button>
120 </div>
121 </div>
122 </form>
123 );
124 }
125
125 lines Plain Text