Skip to content
261 linesCodeBlameRaw
1import { ArrowUp, AtSign, Smile } from "lucide-react";
2import { type KeyboardEvent, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
3
4import { AgentPill, MemberAvatar } from "./marks";
5import { Hint } from "../ui/hint";
6// `:shortcode` completion and the emoji picker (components/emoji).
7import { useEmojiAutocomplete } from "../emoji/autocomplete";
8import { EmojiPickerPopover } from "../emoji/picker";
9import { type MentionQuery, type Mentionable, mentionQuery } from "../../lib/chat";
10
11/** How tall the box grows before it scrolls. */
12const MAX_HEIGHT = 240;
13
14/**
15 * Where a message is written. Enter sends, Shift+Enter starts a new line;
16 * `@` offers the workspace's people and agents (Up and Down to move, Tab or
17 * Enter to take one, Escape to dismiss). What is typed is kept per
18 * conversation while the tab is open, so moving away loses nothing.
19 */
20export function Composer({
21 draftKey,
22 placeholder,
23 people,
24 onSend,
25 onTyping,
26 disabled,
27 autoFocus,
28 compact,
29}: {
30 draftKey: string;
31 placeholder: string;
32 people: Mentionable[];
33 onSend: (body: string) => void;
34 onTyping?: () => void;
35 disabled?: boolean;
36 autoFocus?: boolean;
37 compact?: boolean;
38}) {
39 const box = useRef<HTMLTextAreaElement>(null);
40 const [text, setText] = useState("");
41 const [match, setMatch] = useState<MentionQuery | null>(null);
42 const [active, setActive] = useState(0);
43 const list = useId();
44 const emoji = useEmojiAutocomplete({ box, setText });
45
46 // The draft for this conversation, if one was left.
47 useEffect(() => {
48 try {
49 setText(sessionStorage.getItem(`chat-draft:${draftKey}`) ?? "");
50 } catch {
51 setText("");
52 }
53 }, [draftKey]);
54 useEffect(() => {
55 try {
56 if (text) sessionStorage.setItem(`chat-draft:${draftKey}`, text);
57 else sessionStorage.removeItem(`chat-draft:${draftKey}`);
58 } catch {
59 // No storage: the draft lives only while the page does.
60 }
61 }, [draftKey, text]);
62
63 // Grows with what is written, up to a point.
64 useLayoutEffect(() => {
65 const element = box.current;
66 if (!element) return;
67 element.style.height = "auto";
68 element.style.height = `${Math.min(MAX_HEIGHT, element.scrollHeight)}px`;
69 }, [text]);
70
71 const look = () => {
72 const element = box.current;
73 if (!element) return;
74 emoji.look();
75 setMatch(element.selectionStart === element.selectionEnd ? mentionQuery(element.value, element.selectionStart, people) : null);
76 setActive(0);
77 };
78
79 const complete = (person: Mentionable) => {
80 const element = box.current;
81 if (!element || !match) return;
82 const caret = element.selectionStart;
83 const next = `${element.value.slice(0, match.start)}@${person.name} ${element.value.slice(caret)}`;
84 setText(next);
85 setMatch(null);
86 requestAnimationFrame(() => {
87 const at = match.start + person.name.length + 2;
88 element.focus();
89 element.setSelectionRange(at, at);
90 });
91 };
92
93 const send = () => {
94 const body = text.trim();
95 if (!body || disabled) return;
96 onSend(body);
97 setText("");
98 setMatch(null);
99 };
100
101 const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
102 if (emoji.onKeyDown(event)) return;
103 if (match) {
104 const count = match.options.length;
105 if (event.key === "ArrowDown" || event.key === "ArrowUp") {
106 event.preventDefault();
107 setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
108 return;
109 }
110 if ((event.key === "Tab" || event.key === "Enter") && !event.shiftKey) {
111 event.preventDefault();
112 complete(match.options[active] ?? match.options[0]!);
113 return;
114 }
115 if (event.key === "Escape") {
116 event.preventDefault();
117 setMatch(null);
118 return;
119 }
120 }
121 // Enter sends; Shift+Enter, and Enter while composing an IME word, does not.
122 if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
123 event.preventDefault();
124 send();
125 }
126 };
127
128 const open = match != null;
129 return (
130 <div className="relative">
131 {emoji.menu}
132 {match && (
133 <ul
134 id={list}
135 role="listbox"
136 aria-label="Mention someone"
137 className="absolute bottom-full left-0 z-20 mb-2 w-full max-w-sm overflow-hidden rounded-lg border border-line-strong bg-raised p-1 shadow-xl shadow-black/40"
138 >
139 {match.options.map((person, index) => (
140 <li
141 key={`${person.kind}:${person.name}`}
142 id={`${list}-${index}`}
143 role="option"
144 aria-selected={index === active}
145 onMouseDown={(event) => event.preventDefault()}
146 onMouseEnter={() => setActive(index)}
147 onClick={() => complete(person)}
148 className={`flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 ${index === active ? "bg-line" : ""}`}
149 >
150 <MemberAvatar member={person} size={22} />
151 <span className="min-w-0 grow truncate text-sm">
152 <span className="font-medium text-fg">{person.display_name}</span>
153 <span className="ml-1.5 text-faint">@{person.name}</span>
154 </span>
155 {person.kind === "agent" && <AgentPill />}
156 </li>
157 ))}
158 </ul>
159 )}
160 <div
161 className={`rounded-xl border border-line-strong bg-surface transition-colors focus-within:border-accent/35 focus-within:ring-4 focus-within:ring-accent/[0.06] ${disabled ? "opacity-60" : ""}`}
162 >
163 <textarea
164 ref={box}
165 value={text}
166 rows={1}
167 disabled={disabled}
168 autoFocus={autoFocus}
169 placeholder={placeholder}
170 aria-label={placeholder}
171 role={open ? "combobox" : undefined}
172 aria-autocomplete="list"
173 aria-expanded={open}
174 aria-controls={open ? list : undefined}
175 aria-activedescendant={open ? `${list}-${active}` : undefined}
176 autoComplete="off"
177 data-1p-ignore
178 data-lpignore="true"
179 onChange={(event) => {
180 setText(event.target.value);
181 onTyping?.();
182 }}
183 onInput={look}
184 onClick={look}
185 onKeyDown={onKeyDown}
186 onBlur={() =>
187 setTimeout(() => {
188 setMatch(null);
189 emoji.dismiss();
190 }, 150)
191 }
192 className={`block w-full resize-none bg-transparent px-3.5 text-[0.9375rem] leading-6 text-fg outline-none placeholder:text-faint ${compact ? "pt-2.5 pb-1" : "pt-3 pb-1.5"}`}
193 />
194 <div className="flex items-center gap-1 px-2 pb-2">
195 <Hint label="Mention someone">
196 <button
197 type="button"
198 aria-label="Mention someone"
199 disabled={disabled}
200 onClick={() => {
201 const element = box.current;
202 if (!element) return;
203 const at = element.selectionStart;
204 const before = element.value.slice(0, at);
205 const insert = before && !/\s$/.test(before) ? " @" : "@";
206 const next = `${before}${insert}${element.value.slice(at)}`;
207 setText(next);
208 requestAnimationFrame(() => {
209 element.focus();
210 element.setSelectionRange(at + insert.length, at + insert.length);
211 setMatch(mentionQuery(next, at + insert.length, people));
212 });
213 }}
214 className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
215 >
216 <AtSign size={15} />
217 </button>
218 </Hint>
219 <EmojiPickerPopover
220 side="top"
221 align="start"
222 onPick={(picked) => {
223 const element = box.current;
224 const at = element?.selectionStart ?? text.length;
225 const end = element?.selectionEnd ?? at;
226 const next = `${text.slice(0, at)}${picked}${text.slice(end)}`;
227 setText(next);
228 requestAnimationFrame(() => {
229 element?.focus();
230 element?.setSelectionRange(at + picked.length, at + picked.length);
231 });
232 }}
233 >
234 <Hint label="Add an emoji">
235 <button
236 type="button"
237 aria-label="Add an emoji"
238 disabled={disabled}
239 className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
240 >
241 <Smile size={15} />
242 </button>
243 </Hint>
244 </EmojiPickerPopover>
245 <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
246 <kbd className="font-sans">Enter</kbd> to send · <kbd className="font-sans">Shift+Enter</kbd> for a new line
247 </span>
248 <button
249 type="button"
250 aria-label="Send"
251 disabled={disabled || !text.trim()}
252 onClick={send}
253 className="ml-auto flex size-8 items-center justify-center rounded-lg bg-accent text-bg transition-[background-color,opacity] hover:bg-accent-hover disabled:bg-line disabled:text-faint"
254 >
255 <ArrowUp size={16} strokeWidth={2.4} />
256 </button>
257 </div>
258 </div>
259 </div>
260 );
261}