Skip to content
610 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.

Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1import {
2 ArrowUpRight,
3 Bot,
4 CircleAlert,
5 CircleCheck,
6 CircleDot,
7 CircleDotDashed,
Merge branch 'worktree-agent-a1398e81ad1a64c5f'8 Forward,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar9 GitPullRequest,
10 ListChecks,
11 LoaderCircle,
12 Rocket,
13 ShieldCheck,
14 Sparkles,
15 X,
16} from "lucide-react";
17import {
18 type FormEvent,
19 type KeyboardEvent,
20 type ReactNode,
21 useEffect,
22 useId,
23 useRef,
24 useState,
25 useSyncExternalStore,
26} from "react";
27import { Link } from "react-router";
28
29import type { CardAction, CardActionResult, MessageCard, Result } from "@g1t/contracts";
30
31import { Markdown } from "../markdown";
32import {
33 AlertDialog,
34 AlertDialogAction,
35 AlertDialogCancel,
36 AlertDialogContent,
37 AlertDialogDescription,
38 AlertDialogFooter,
39 AlertDialogHeader,
40 AlertDialogTitle,
41} from "../ui/alert-dialog";
42import { Badge, type BadgeTone } from "../ui/badge";
43import { InputAddon, InputGroup } from "../ui/input";
44import { cn } from "../../lib/cn";
45import { type CardChip, actionMode, cardChip, foldsBody, inputValue, moneyInitial, shownActions } from "../../lib/card-actions";
46import { repoAt } from "../../lib/markdown-plugins";
47import { sessionChip } from "../../lib/session-card";
48import { codeAccessPath } from "../../lib/workspace-nav";
49
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.50// A card g1t or an agent posts in chat: what
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar51// it is about, its state, a preview, a few facts, and what people can do
52// right here. Pressing an action goes to the site (routes/workspace/chat/
53// api.ts, `card_action`), then to chat, then to the service that owns the
54// card; the card changes in place when that service updates it
55// (`message.updated`), so nothing here changes it ahead of time.
56
57const CARD_ICONS: Record<string, ReactNode> = {
58 pull: <GitPullRequest size={16} />,
59 issue: <CircleDot size={16} />,
60 draft_issue: <CircleDotDashed size={16} />,
61 task: <ListChecks size={16} />,
62 deploy: <Rocket size={16} />,
63 approval: <ShieldCheck size={16} />,
64 session: <Bot size={16} />,
Merge branch 'worktree-agent-a1398e81ad1a64c5f'65 // Where an agent handed the work: the group message it went to.
66 handoff: <Forward size={16} />,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar67};
68
69/** Where a card's link goes for this viewer: a member without Code goes to the workspace's own view of it. */
70export function cardHref(href: string | null | undefined, slug: string, code: boolean): string | null {
71 if (!href) return null;
72 if (code || !/^\/[^/]+\/(?!-\/)[^/]+/.test(href)) return href;
73 return codeAccessPath(slug, href);
74}
75
76/** How loud a card's state reads, from what it says, for kinds without chips of their own. */
77function stateTone(state: string): BadgeTone {
78 const s = state.toLowerCase();
79 if (/merged/.test(s)) return "merged";
80 if (/fail|error|blocked|over budget|rejected/.test(s)) return "danger";
81 if (/approval|waiting|review|queued|needs/.test(s)) return "warn";
82 if (/pass|done|deployed|ready|approved|live|success/.test(s)) return "success";
83 if (/working|running|in progress|open/.test(s)) return "info";
84 return "neutral";
85}
86
87function chipOf(card: MessageCard): CardChip | null {
88 if (!card.state) return null;
89 if (card.kind === "session") return sessionChip(card.state);
90 return cardChip(card) ?? { tone: stateTone(card.state), live: false };
91}
92
93/**
94 * A card's state, as a chip: a session's Working pulses softly in lavender,
95 * Needs approval is amber, Done green; a draft issue is outlined lavender
96 * until it is filed (green) or discarded (quiet).
97 */
98function StateChip({ state, chip }: { state: string; chip: CardChip }) {
99 return (
100 <Badge tone={chip.tone} className={cn("mt-0.5", chip.outline && "bg-transparent")}>
101 {chip.live && (
102 <span aria-hidden="true" className="relative flex size-1.5">
103 <span className="absolute inline-flex size-full animate-ping rounded-full bg-current opacity-60 motion-reduce:animate-none" />
104 <span className="relative inline-flex size-1.5 rounded-full bg-current" />
105 </span>
106 )}
107 {state}
108 </Badge>
109 );
110}
111
112// ── What pressing an action said, as a toast above the composer.
113
114type CardToast = { id: number; ok: boolean; message: string };
115
116const TOAST_MS = 4_500;
117const NO_TOASTS: CardToast[] = [];
118let toasts: CardToast[] = NO_TOASTS;
119let toastSeq = 0;
120const toastListeners = new Set<() => void>();
121
122function setToasts(next: CardToast[]) {
123 toasts = next;
124 for (const listener of toastListeners) listener();
125}
126
127function dismissToast(id: number) {
128 setToasts(toasts.filter((t) => t.id !== id));
129}
130
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread131/** Says something in the conversation's toasts: what a card's action did, or that a link was copied. */
132export function showToast(ok: boolean, message: string) {
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar133 const id = ++toastSeq;
134 // Three at most; a fourth pushes the oldest out.
135 setToasts([...toasts.slice(-2), { id, ok, message }]);
136 setTimeout(() => dismissToast(id), ok ? TOAST_MS : TOAST_MS * 1.5);
137}
138
139function subscribeToasts(listener: () => void) {
140 toastListeners.add(listener);
141 return () => toastListeners.delete(listener);
142}
143
144/**
145 * The toasts that say what a card's action did. Put once in a conversation;
146 * along the top on a phone, above the composer on a computer, clear of the
147 * notification toasts in the corner.
148 */
149export function CardToasts() {
150 const list = useSyncExternalStore(
151 subscribeToasts,
152 () => toasts,
153 () => NO_TOASTS,
154 );
155 return (
156 <section
157 aria-label="Card actions"
158 aria-live="polite"
159 className="pointer-events-none fixed z-[66] flex flex-col items-center gap-2 max-sm:inset-x-3 max-sm:top-[calc(env(safe-area-inset-top)+0.75rem)] sm:bottom-28 sm:left-1/2 sm:w-[min(26rem,calc(100vw-2rem))] sm:-translate-x-1/2"
160 >
161 {list.map((toast) => (
162 <div
163 key={toast.id}
164 role={toast.ok ? "status" : "alert"}
165 className={cn(
You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how.166 "pointer-events-auto flex w-full animate-pop-in items-start gap-2.5 rounded-xl bg-surface py-2.5 pr-2 pl-3 text-[0.8125rem] leading-snug text-fg shadow-[0_0_0_6px_var(--color-bg),0_20px_48px_-12px_rgba(0,0,0,0.9)] ring-1 motion-reduce:animate-none",
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar167 toast.ok ? "ring-success/35" : "ring-danger/45",
168 )}
169 >
170 <span className={cn("mt-px shrink-0", toast.ok ? "text-success" : "text-danger")} aria-hidden="true">
171 {toast.ok ? <CircleCheck size={16} /> : <CircleAlert size={16} />}
172 </span>
173 <p className="min-w-0 grow">{toast.message}</p>
174 <button
175 type="button"
176 aria-label="Dismiss"
177 onClick={() => dismissToast(toast.id)}
178 className="flex size-6 shrink-0 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
179 >
180 <X size={14} />
181 </button>
182 </div>
183 ))}
184 </section>
185 );
186}
187
188// ── The card.
189
190const ACTION_BASE =
191 "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-[0.8125rem] font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:pointer-events-none disabled:opacity-50 max-md:h-10 max-md:px-3.5";
192
193const ACTION_STYLES: Record<NonNullable<CardAction["style"]>, string> = {
194 primary: "bg-accent text-bg hover:bg-accent-hover",
195 danger: "border border-danger/40 text-danger hover:border-danger hover:bg-danger/10",
196 default: "border border-line text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg",
197};
198
199function actionClass(action: CardAction, active = false) {
200 return cn(ACTION_BASE, ACTION_STYLES[action.style ?? "default"], active && "border-accent/60 bg-raised text-fg");
201}
202
203/** Sends a card's action; says what happened in a toast; true when it was done. */
204async function pressAction(slug: string, channelId: string, messageId: string, action: CardAction, input: string | null): Promise<boolean> {
205 try {
206 const response = await fetch(`/${slug}/-/chat/api`, {
207 method: "POST",
208 headers: { "content-type": "application/json" },
209 body: JSON.stringify({ intent: "card_action", channel_id: channelId, message_id: messageId, action_id: action.id, input }),
210 });
211 const result = (await response.json()) as Result<CardActionResult>;
212 if (!result.ok) {
213 showToast(false, result.error.message);
214 return false;
215 }
216 showToast(result.value.ok, result.value.message ?? (result.value.ok ? `${action.label}: done.` : `${action.label} didn't work.`));
217 return result.value.ok;
218 } catch {
219 showToast(false, `${action.label} didn't go through. Try again in a moment.`);
220 return false;
221 }
222}
223
224/**
225 * A card g1t or an agent posted: its title (a link when it goes somewhere),
226 * its state, a line of detail, a preview, its facts, and its actions.
227 */
228export function CardBox({
229 card,
230 slug,
231 code,
232 channelId,
233 messageId,
234 inert = false,
235}: {
236 card: MessageCard;
237 slug: string;
238 /** Whether the viewer uses Code, for where links go. */
239 code: boolean;
240 channelId: string;
241 messageId: string;
242 /** Not yet sent, or gone: drawn, but nothing on it can be pressed. */
243 inert?: boolean;
244}) {
245 const href = cardHref(card.href, slug, code);
246 const chip = chipOf(card);
247 const live = card.kind === "session" && !!chip?.live;
248 const actions = shownActions(card);
249 const [busy, setBusy] = useState<string | null>(null);
250 const [asking, setAsking] = useState<string | null>(null);
251 const [confirming, setConfirming] = useState<CardAction | null>(null);
252 const triggers = useRef(new Map<string, HTMLButtonElement>());
253 const panelId = useId();
254 const askingAction = actions.find((a) => a.id === asking && a.input) ?? null;
255
256 // The card changed under an open field (the session finished, say): the
257 // action it was for is gone, so the field goes too.
258 useEffect(() => {
259 if (asking && !askingAction) setAsking(null);
260 }, [asking, askingAction]);
261
262 const run = async (action: CardAction, input: string | null = null) => {
263 if (busy || inert) return false;
264 setBusy(action.id);
265 const done = await pressAction(slug, channelId, messageId, action, input);
266 setBusy(null);
267 return done;
268 };
269
270 const press = (action: CardAction) => {
271 switch (actionMode(action)) {
272 case "input":
273 setAsking((now) => (now === action.id ? null : action.id));
274 return;
275 case "confirm":
276 setConfirming(action);
277 return;
278 case "run":
279 void run(action);
280 return;
281 }
282 };
283
284 const closeField = (refocus: string | null) => {
285 setAsking(null);
286 if (refocus) requestAnimationFrame(() => triggers.current.get(refocus)?.focus());
287 };
288
289 const head = (
290 <>
291 <span
292 className={cn(
293 "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors",
294 live ? "bg-accent/10 text-accent" : "bg-raised text-muted group-hover/card:text-fg",
295 )}
296 >
297 {CARD_ICONS[card.kind] ?? <Sparkles size={16} />}
298 </span>
299 <span className="min-w-0 grow">
300 <span className={cn("line-clamp-2 text-sm font-medium text-fg", href && "group-hover/head:underline group-hover/head:decoration-line-strong group-hover/head:underline-offset-2")}>
301 {card.title}
302 </span>
303 {card.detail && <span className="mt-0.5 block truncate font-mono text-xs text-muted tabular-nums">{card.detail}</span>}
304 </span>
305 {card.state && chip && <StateChip state={card.state} chip={chip} />}
306 </>
307 );
308
309 const repository = card.fields?.find((f) => f.label.toLowerCase() === "repository")?.value;
310
311 return (
312 <div
313 className={cn(
314 "group/card mt-1.5 w-full max-w-xl rounded-xl border bg-surface px-3.5 py-3 transition-[transform,box-shadow,border-color] duration-150",
315 "hover:-translate-y-px hover:border-line-strong hover:shadow-lg hover:shadow-black/25 motion-reduce:transition-none motion-reduce:hover:translate-y-0",
316 live ? "border-accent/25" : "border-line",
317 )}
318 >
319 {href ? (
320 <Link to={href} className="group/head -m-1 flex items-start gap-3 rounded-lg p-1 outline-none focus-visible:ring-2 focus-visible:ring-accent/50">
321 {head}
322 </Link>
323 ) : (
324 <div className="flex items-start gap-3">{head}</div>
325 )}
326
327 {card.body && <CardBody body={card.body} repo={repository} />}
328
329 {card.fields && card.fields.length > 0 && (
330 <dl className="mt-2.5 grid grid-cols-2 gap-x-4 gap-y-2 sm:pl-11">
331 {card.fields.map((field) => (
332 <div key={field.label} className="min-w-0">
333 <dt className="text-[0.6875rem] font-medium text-faint">{field.label}</dt>
334 <dd className="line-clamp-2 text-xs break-words text-fg-soft">
335 {field.value}
336 </dd>
337 </div>
338 ))}
339 </dl>
340 )}
341
342 {actions.length > 0 && (
343 <div
344 role="group"
345 aria-label={`${card.title}: actions`}
346 className="mt-3 flex flex-wrap items-center gap-2 sm:pl-11"
347 aria-busy={busy ? true : undefined}
348 // A press here is for the button, not the message's long-press sheet.
349 onTouchStart={(event) => event.stopPropagation()}
350 >
351 {actions.map((action) => {
352 const target = actionMode(action) === "link" ? cardHref(action.href, slug, code) : null;
353 if (target) {
354 return (
355 <Link key={action.id} to={target} className={actionClass(action)}>
356 {action.label}
357 <ArrowUpRight size={14} aria-hidden="true" className="-mr-0.5 text-faint" />
358 </Link>
359 );
360 }
361 const working = busy === action.id;
362 const opens = !!action.input;
363 return (
364 <button
365 key={action.id}
366 ref={(element) => {
367 if (element) triggers.current.set(action.id, element);
368 else triggers.current.delete(action.id);
369 }}
370 type="button"
371 disabled={inert || !!busy}
372 aria-busy={working || undefined}
373 aria-expanded={opens ? asking === action.id : undefined}
374 aria-controls={opens && asking === action.id ? panelId : undefined}
375 aria-haspopup={action.confirm && !opens ? "dialog" : undefined}
376 onClick={() => press(action)}
377 className={actionClass(action, opens && asking === action.id)}
378 >
379 {working && <LoaderCircle size={14} aria-hidden="true" className="-ml-0.5 animate-spin" />}
380 {action.label}
381 </button>
382 );
383 })}
384 </div>
385 )}
386
387 {askingAction && (
388 <ActionField
389 key={askingAction.id}
390 id={panelId}
391 action={askingAction}
392 busy={busy === askingAction.id}
393 disabled={inert || !!busy}
394 onCancel={() => closeField(askingAction.id)}
395 onSubmit={async (value) => {
396 if (await run(askingAction, value)) closeField(null);
397 }}
398 />
399 )}
400
401 <AlertDialog open={!!confirming} onOpenChange={(open) => !open && setConfirming(null)}>
402 <AlertDialogContent>
403 <AlertDialogHeader>
404 <AlertDialogTitle>{confirming?.label}</AlertDialogTitle>
405 <AlertDialogDescription>{confirming?.confirm}</AlertDialogDescription>
406 </AlertDialogHeader>
407 <AlertDialogFooter>
408 <AlertDialogCancel>Cancel</AlertDialogCancel>
409 <AlertDialogAction
410 className={confirming?.style === "danger" ? undefined : "bg-accent text-bg hover:bg-accent-hover"}
411 onClick={() => {
412 const action = confirming;
413 setConfirming(null);
414 if (action) void run(action);
415 }}
416 >
417 {confirming?.label}
418 </AlertDialogAction>
419 </AlertDialogFooter>
420 </AlertDialogContent>
421 </AlertDialog>
422 </div>
423 );
424}
425
426/** A card's Markdown preview: about eight lines, then "Show more". */
427function CardBody({ body, repo }: { body: string; repo?: string }) {
428 const [open, setOpen] = useState(false);
429 // Folded by its length at first (the same on the server), then by whether it really overflows.
430 const [folds, setFolds] = useState(() => foldsBody(body));
431 const box = useRef<HTMLDivElement>(null);
432 const id = useId();
433 useEffect(() => {
434 const element = box.current;
435 if (!element || open) return;
436 const measure = () => setFolds(element.scrollHeight > element.clientHeight + 2);
437 measure();
438 const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
439 observer?.observe(element);
440 return () => observer?.disconnect();
441 }, [body, open]);
442 const folded = folds && !open;
443 return (
444 <div className="mt-2.5 sm:pl-11">
445 <div
446 ref={box}
447 id={id}
448 className={cn("card-md relative rounded-lg border border-line/70 bg-bg/40 px-3 py-2", !open && "max-h-[11.5rem] overflow-hidden")}
449 >
450 <Markdown source={body} repo={repoAt(repo)} />
451 {folded && (
452 <span aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-12 rounded-b-lg bg-gradient-to-t from-surface to-transparent" />
453 )}
454 </div>
455 {(folded || open) && (
456 <button
457 type="button"
458 aria-expanded={open}
459 aria-controls={id}
460 onClick={() => setOpen((now) => !now)}
461 className="mt-1 rounded px-1 py-0.5 text-xs font-medium text-accent transition-colors outline-none hover:bg-raised focus-visible:ring-2 focus-visible:ring-accent/50 max-md:min-h-9"
462 >
463 {open ? "Show less" : "Show more"}
464 </button>
465 )}
466 </div>
467 );
468}
469
470/**
471 * The value an action needs, asked right under the card: an amount with a
472 * "$" before it and the suggestion filled in, or a line of text that grows
473 * (Enter sends, Shift+Enter is a new line). Escape puts it away.
474 */
475function ActionField({
476 id,
477 action,
478 busy,
479 disabled,
480 onSubmit,
481 onCancel,
482}: {
483 id: string;
484 action: CardAction;
485 busy: boolean;
486 disabled: boolean;
487 onSubmit: (value: string) => void;
488 onCancel: () => void;
489}) {
490 const input = action.input!;
491 const money = input.kind === "money";
492 const [value, setValue] = useState(() => (money ? moneyInitial(input.initial) : (input.initial ?? "")));
493 const [tried, setTried] = useState(false);
494 const field = useRef<HTMLInputElement & HTMLTextAreaElement>(null);
495 const labelId = useId();
496 const errorId = useId();
497 const ready = inputValue(action, value);
498 const invalid = tried && !ready;
499
500 useEffect(() => {
501 const element = field.current;
502 if (!element) return;
503 element.focus();
504 if (money) element.select();
505 }, [money]);
506
507 // A line of text grows with what is written, up to about six lines.
508 useEffect(() => {
509 const element = field.current;
510 if (money || !element) return;
511 element.style.height = "auto";
512 element.style.height = `${Math.min(element.scrollHeight, 160)}px`;
513 }, [value, money]);
514
515 const submit = (event?: FormEvent) => {
516 event?.preventDefault();
517 if (disabled) return;
518 if (!ready) {
519 setTried(true);
520 field.current?.focus();
521 return;
522 }
523 onSubmit(ready);
524 };
525
526 const onKeyDown = (event: KeyboardEvent) => {
527 if (event.key === "Escape") {
528 event.preventDefault();
529 event.stopPropagation();
530 onCancel();
531 return;
532 }
533 if (!money && event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
534 event.preventDefault();
535 submit();
536 }
537 };
538
539 return (
540 <form
541 id={id}
542 onSubmit={submit}
543 onTouchStart={(event) => event.stopPropagation()}
544 className="mt-3 animate-pop-in sm:pl-11 motion-reduce:animate-none"
545 aria-labelledby={labelId}
546 >
547 <label id={labelId} htmlFor={`${id}-field`} className="mb-1.5 block text-xs font-medium text-muted">
548 {input.label || action.label}
549 </label>
550 <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
551 {money ? (
552 <InputGroup className={cn("sm:max-w-44 max-md:h-10", invalid && "border-danger/70")}>
553 <InputAddon className="pr-0 font-mono text-muted">$</InputAddon>
554 <input
555 ref={field}
556 id={`${id}-field`}
557 value={value}
558 onChange={(event) => {
559 setValue(event.target.value);
560 setTried(false);
561 }}
562 onKeyDown={onKeyDown}
563 inputMode="decimal"
564 autoComplete="off"
565 data-1p-ignore
566 placeholder={input.placeholder ?? "0.00"}
567 aria-invalid={invalid || undefined}
568 aria-describedby={invalid ? errorId : undefined}
569 className="w-full min-w-0 bg-transparent pr-3 pl-1 font-mono text-sm text-fg tabular-nums outline-none placeholder:text-faint"
570 />
571 </InputGroup>
572 ) : (
573 <textarea
574 ref={field}
575 id={`${id}-field`}
576 rows={1}
577 value={value}
578 onChange={(event) => {
579 setValue(event.target.value);
580 setTried(false);
581 }}
582 onKeyDown={onKeyDown}
583 autoComplete="off"
584 data-1p-ignore
585 placeholder={input.placeholder ?? undefined}
586 aria-invalid={invalid || undefined}
587 aria-describedby={invalid ? errorId : undefined}
588 className="min-h-9 w-full min-w-0 grow resize-none rounded-md border border-line bg-bg px-3 py-[0.4375rem] text-sm leading-5 text-fg outline-none transition-[border-color,box-shadow] placeholder:text-faint hover:border-line-strong focus-visible:border-accent-dim focus-visible:ring-2 focus-visible:ring-accent/25 aria-invalid:border-danger/70 max-md:min-h-10 max-md:text-base"
589 />
590 )}
591 <div className="flex shrink-0 gap-2 max-sm:[&>*]:grow">
592 <button type="button" onClick={onCancel} className={actionClass({ id: "cancel", label: "Cancel" })}>
593 Cancel
594 </button>
595 <button type="submit" disabled={disabled} aria-busy={busy || undefined} className={actionClass({ ...action, style: action.style === "danger" ? "danger" : "primary" })}>
596 {busy && <LoaderCircle size={14} aria-hidden="true" className="-ml-0.5 animate-spin" />}
597 {action.label}
598 </button>
599 </div>
600 </div>
601 {invalid ? (
602 <p id={errorId} className="mt-1.5 text-xs text-danger">
603 {money ? "Enter an amount in dollars, like 5.00." : "Write something first."}
604 </p>
605 ) : (
606 !money && <p className="mt-1.5 text-[0.6875rem] text-faint max-md:hidden">Enter to send, Shift+Enter for a new line, Esc to close</p>
607 )}
608 </form>
609 );
610}

This file's history is long; its oldest lines are credited to the oldest commit read.