Skip to content
223 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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