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

Merge main into Artifacts Phase 21import { Extension, markInputRule, type Editor } from "@tiptap/core";
2import { Slice } from "@tiptap/pm/model";
3import { Placeholder } from "@tiptap/extensions";
4import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
5import StarterKit from "@tiptap/starter-kit";
6import {
7 ArrowUp,
8 AtSign,
9 Bold,
10 Code,
11 Italic,
12 Keyboard,
13 Link as LinkIcon,
14 List,
15 ListOrdered,
16 Smile,
17 SquareCode,
18 Strikethrough,
19 TextQuote,
20 Type,
21 Unlink,
22} from "lucide-react";
23import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject, useEffect, useId, useMemo, useRef, useState } from "react";
24
25import { AgentPill, MemberAvatar } from "./marks";
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.26import { Button } from "../ui/button";
Merge main into Artifacts Phase 227import { Hint } from "../ui/hint";
28import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
29// `:shortcode` completion and the emoji picker (components/emoji).
30import { useEmojiAutocomplete } from "../emoji/autocomplete";
31import { EmojiPickerPopover } from "../emoji/picker";
32import { type MentionQuery, type Mentionable, mentionQuery, safeHref, shownHandle } from "../../lib/chat";
33import { docToMarkdown, markdownToDoc } from "../../lib/chat-compose";
34
35/** How tall the box grows before it scrolls. */
36const MAX_HEIGHT = 240;
37
38export type ComposerProps = {
39 /** Where the draft is kept while the tab is open; none when editing a message. */
40 draftKey?: string;
41 placeholder: string;
42 people: Mentionable[];
43 onSend: (body: string) => void;
44 onTyping?: () => void;
45 disabled?: boolean;
46 autoFocus?: boolean;
47 compact?: boolean;
48 /** Editing a message: its Markdown to start from, and what leaving does. */
49 initial?: string;
50 onCancel?: () => void;
51};
52
53// ---------------------------------------------------------------------------
54// The editor's rules: Markdown typed as you go, and the shortcuts.
55
56/** `*bold*` (one star, as chat writes it) and `**bold**`; `_italic_`; `~strike~` and `~~strike~~`. */
57const ChatMarks = Extension.create({
58 name: "chatMarks",
59 // Before the marks' own rules, so `*x*` is bold here, not italic.
60 priority: 1000,
61 addInputRules() {
62 const { bold, italic, strike } = this.editor.schema.marks;
63 return [
64 markInputRule({ find: /(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))$/, type: bold! }),
65 markInputRule({ find: /(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))$/, type: bold! }),
66 markInputRule({ find: /(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))$/, type: italic! }),
67 markInputRule({ find: /(?:^|\s)(~(?!\s+~)((?:[^~]+))~(?!\s+~))$/, type: strike! }),
68 ];
69 },
70});
71
72/** The shortcuts beyond the editor's own (Mod+B, Mod+I, Mod+E, Mod+Shift+7 and 8). */
73function chatKeys(openLink: RefObject<() => void>) {
74 return Extension.create({
75 name: "chatKeys",
76 addKeyboardShortcuts() {
77 return {
78 "Mod-Shift-x": () => this.editor.commands.toggleStrike(),
79 "Mod-Shift-c": () => this.editor.commands.toggleCode(),
80 "Mod-Alt-Shift-c": () => this.editor.commands.toggleCodeBlock(),
81 "Mod-Shift-9": () => this.editor.commands.toggleBlockquote(),
82 "Mod-k": () => {
83 openLink.current();
84 return true;
85 },
86 };
87 },
88 });
89}
90
91/** Whether the caret is inside a node of this type. */
92function inside(editor: Editor, type: string): boolean {
93 const { $from } = editor.state.selection;
94 for (let depth = $from.depth; depth > 0; depth--) if ($from.node(depth).type.name === type) return true;
95 return false;
96}
97
98/**
99 * The block the caret is in, as a text box: what the `@` and `:` menus
100 * read and write, as they would a textarea. Each character of the block's
101 * text is one position in the document.
102 */
103type TextBox = { value: string; selectionStart: number; selectionEnd: number; focus: () => void; setSelectionRange: (from: number, to: number) => void };
104
105function textBox(editor: Editor): TextBox {
106 return {
107 get value() {
108 const parent = editor.state.selection.$from.parent;
109 return parent.textBetween(0, parent.content.size, undefined, "");
110 },
111 get selectionStart() {
112 return editor.state.selection.$from.parentOffset;
113 },
114 get selectionEnd() {
115 const { $from, $to } = editor.state.selection;
116 return $from.sameParent($to) ? $to.parentOffset : $from.parentOffset;
117 },
118 focus: () => editor.commands.focus(),
119 setSelectionRange: (from, to) => {
120 const start = editor.state.selection.$from.start();
121 editor.commands.setTextSelection({ from: start + from, to: start + to });
122 },
123 };
124}
125
126/** The caret's block made to read `next`: only what changed is replaced, so its formatting stays. */
127function replaceBlockText(editor: Editor, next: string) {
128 const box = textBox(editor);
129 const now = box.value;
130 let head = 0;
131 while (head < now.length && head < next.length && now[head] === next[head]) head++;
132 let tail = 0;
133 while (tail < now.length - head && tail < next.length - head && now[now.length - 1 - tail] === next[next.length - 1 - tail]) tail++;
134 const start = editor.state.selection.$from.start();
135 const inserted = next.slice(head, next.length - tail);
136 const tr = editor.state.tr;
137 if (inserted) tr.insertText(inserted, start + head, start + now.length - tail);
138 else tr.delete(start + head, start + now.length - tail);
139 editor.view.dispatch(tr);
140}
141
142/** What a link field holds: an address as typed, made into one a link may go to. */
143function linkAddress(typed: string): string | null {
144 const trimmed = typed.trim();
145 if (!trimmed) return null;
146 if (/^[\w.+-]+@[\w-]+\.[\w.-]+$/.test(trimmed)) return safeHref(`mailto:${trimmed}`);
147 if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed) && !trimmed.startsWith("/")) return safeHref(`https://${trimmed}`);
148 return safeHref(trimmed);
149}
150
151const MOD = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? "⌘" : "Ctrl";
152
153/** Every formatting shortcut, for the hints and the list. */
154const SHORTCUTS: { label: string; keys: string[] }[] = [
155 { label: "Bold", keys: [MOD, "B"] },
156 { label: "Italic", keys: [MOD, "I"] },
157 { label: "Strikethrough", keys: [MOD, "Shift", "X"] },
158 { label: "Link", keys: [MOD, "K"] },
159 { label: "Code", keys: [MOD, "Shift", "C"] },
160 { label: "Code block", keys: [MOD, "Alt", "Shift", "C"] },
161 { label: "Bulleted list", keys: [MOD, "Shift", "8"] },
162 { label: "Numbered list", keys: [MOD, "Shift", "7"] },
163 { label: "Quote", keys: [MOD, "Shift", "9"] },
164];
165
166const keysOf = (label: string) => SHORTCUTS.find((s) => s.label === label)!.keys.join("+");
167
168/** Whether the formatting bar is shown: as last chosen on this device, else on wider screens. */
169function useToolbarShown(): [boolean, (shown: boolean) => void] {
170 const [shown, setShown] = useState(true);
171 useEffect(() => {
172 try {
173 const saved = localStorage.getItem("chat-formatting");
174 if (saved) return setShown(saved === "shown");
175 } catch {
176 // No storage: the screen decides.
177 }
178 setShown(window.matchMedia("(min-width: 768px)").matches);
179 }, []);
180 return [
181 shown,
182 (next: boolean) => {
183 setShown(next);
184 try {
185 localStorage.setItem("chat-formatting", next ? "shown" : "hidden");
186 } catch {
187 // Kept only while the page is open.
188 }
189 },
190 ];
191}
192
193// ---------------------------------------------------------------------------
194// The composer.
195
196/**
197 * Where a message is written, formatted as it is typed. Enter sends and
198 * Shift+Enter starts a new line; in a list Enter adds an item (twice to
199 * leave it), in a code block a line (three times to leave it), and
200 * Ctrl+Enter sends from anywhere. Markdown typed inline becomes its
201 * formatting; the bar above the text does the same with a click. `@`
202 * offers the workspace's people and agents and `:` its emoji (Up and Down
203 * to move, Tab or Enter to take one, Escape to dismiss). What is sent is
204 * Markdown. A draft is kept per conversation while the tab is open.
205 */
206export default function ChatEditor({ draftKey, placeholder, people, onSend, onTyping, disabled, autoFocus, compact, initial, onCancel }: ComposerProps) {
207 const editing = initial != null;
208 const [match, setMatch] = useState<MentionQuery | null>(null);
209 const [active, setActive] = useState(0);
210 const [link, setLink] = useState<{ text: string; href: string; existing: boolean } | null>(null);
211 const [toolbar, setToolbar] = useToolbarShown();
212 const list = useId();
213
214 // What the editor's handlers read: the latest of each, not the first render's.
215 const keys = useRef<(event: KeyboardEvent) => boolean>(() => false);
216 const openLink = useRef<() => void>(() => {});
217 const placeholderRef = useRef(placeholder);
218 placeholderRef.current = placeholder;
219 const typing = useRef(onTyping);
220 typing.current = onTyping;
221 const loading = useRef(false);
222
223 const extensions = useMemo(
224 () => [
225 StarterKit.configure({
226 heading: false,
227 horizontalRule: false,
228 underline: false,
229 dropcursor: false,
230 gapcursor: false,
231 trailingNode: false,
232 link: {
233 openOnClick: false,
234 autolink: false,
235 linkOnPaste: true,
236 protocols: ["mailto"],
237 isAllowedUri: (url) => safeHref(url) != null,
238 HTMLAttributes: { rel: "noopener noreferrer nofollow ugc", target: null },
239 },
240 codeBlock: { HTMLAttributes: { spellcheck: "false" } },
241 }),
242 ChatMarks,
243 chatKeys(openLink),
244 Placeholder.configure({ placeholder: () => placeholderRef.current }),
245 ],
246 [],
247 );
248
249 const draftOf = (key: string | undefined): string => {
250 if (editing) return initial ?? "";
251 if (!key) return "";
252 try {
253 return sessionStorage.getItem(`chat-draft:${key}`) ?? "";
254 } catch {
255 return "";
256 }
257 };
258
259 const editor = useEditor({
260 extensions,
261 immediatelyRender: false,
262 autofocus: autoFocus ? "end" : false,
263 editable: !disabled,
264 content: markdownToDoc(draftOf(draftKey)),
265 editorProps: {
266 attributes: {
267 role: "textbox",
268 "aria-multiline": "true",
269 "aria-label": placeholder,
270 "data-1p-ignore": "",
271 "data-lpignore": "true",
272 class: "chat-editor outline-none",
273 },
274 handleKeyDown: (_view, event) => keys.current(event),
275 // Text pasted without formatting is read as Markdown, the way it would be sent.
276 clipboardTextParser: (text, $context, plain, view) => {
277 if (plain || $context.parent.type.spec.code) return null as unknown as Slice;
278 const doc = view.state.schema.nodeFromJSON(markdownToDoc(text));
279 return Slice.maxOpen(doc.content);
280 },
281 },
282 onUpdate: ({ editor: now }) => {
283 if (loading.current) return;
284 look(now);
285 if (!editing && draftKey) {
286 const markdown = docToMarkdown(now.getJSON());
287 try {
288 if (markdown) sessionStorage.setItem(`chat-draft:${draftKey}`, markdown);
289 else sessionStorage.removeItem(`chat-draft:${draftKey}`);
290 } catch {
291 // No storage: the draft lives only while the page does.
292 }
293 }
294 typing.current?.();
295 },
296 onSelectionUpdate: ({ editor: now }) => look(now),
297 onBlur: () =>
298 setTimeout(() => {
299 setMatch(null);
300 emoji.dismiss();
301 }, 150),
302 });
303
304 // The emoji menu works on the caret's block as on a text box.
305 const box = useRef<TextBox | null>(null);
306 box.current = editor ? textBox(editor) : null;
307 const emoji = useEmojiAutocomplete({
308 box: box as unknown as RefObject<HTMLTextAreaElement | null>,
309 setText: (next) => editor && replaceBlockText(editor, next),
310 });
311
312 // Another conversation's draft when the conversation changes.
313 const shownKey = useRef(draftKey);
314 useEffect(() => {
315 if (!editor || shownKey.current === draftKey) return;
316 shownKey.current = draftKey;
317 loading.current = true;
318 editor.commands.setContent(markdownToDoc(draftOf(draftKey)), { emitUpdate: false });
319 loading.current = false;
320 setMatch(null);
321 setLink(null);
322 // eslint-disable-next-line react-hooks/exhaustive-deps
323 }, [editor, draftKey]);
324
325 useEffect(() => {
326 editor?.setEditable(!disabled);
327 }, [editor, disabled]);
328
329 const state = useEditorState({
330 editor,
331 selector: ({ editor: now }) =>
332 now
333 ? {
334 bold: now.isActive("bold"),
335 italic: now.isActive("italic"),
336 strike: now.isActive("strike"),
337 code: now.isActive("code"),
338 link: now.isActive("link"),
339 codeBlock: now.isActive("codeBlock"),
340 bulletList: now.isActive("bulletList"),
341 orderedList: now.isActive("orderedList"),
342 blockquote: now.isActive("blockquote"),
343 empty: now.isEmpty || docToMarkdown(now.getJSON()).trim() === "",
344 }
345 : null,
346 });
347
348 function look(now: Editor) {
349 emoji.look();
350 const { selection } = now.state;
351 if (!selection.empty || inside(now, "codeBlock") || now.isActive("code")) {
352 setMatch(null);
353 return;
354 }
355 const text = textBox(now);
356 setMatch(mentionQuery(text.value, text.selectionStart, people));
357 setActive(0);
358 }
359
360 const complete = (person: Mentionable) => {
361 if (!editor || !match) return;
362 const start = editor.state.selection.$from.start();
363 const caret = editor.state.selection.$from.parentOffset;
364 editor
365 .chain()
366 .focus()
367 .insertContentAt({ from: start + match.start, to: start + caret }, { type: "text", text: `@${person.name} ` })
368 .run();
369 setMatch(null);
370 };
371
372 const send = () => {
373 if (!editor || disabled) return;
374 const body = docToMarkdown(editor.getJSON()).trim();
375 if (!body) return;
376 onSend(body);
377 if (editing) return;
378 editor.commands.clearContent(true);
379 setMatch(null);
380 setLink(null);
381 };
382
383 openLink.current = () => {
384 if (!editor) return;
385 // On a link already: its whole text, to change or remove.
386 if (editor.isActive("link")) editor.chain().extendMarkRange("link").run();
387 const { from, to } = editor.state.selection;
388 setLink({ text: editor.state.doc.textBetween(from, to, " "), href: editor.getAttributes("link").href ?? "", existing: editor.isActive("link") });
389 };
390
391 keys.current = (event) => {
392 if (!editor) return false;
393 if (emoji.onKeyDown(event as unknown as ReactKeyboardEvent<HTMLElement>)) return true;
394 if (match) {
395 const count = match.options.length;
396 if (event.key === "ArrowDown" || event.key === "ArrowUp") {
397 event.preventDefault();
398 setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
399 return true;
400 }
401 if ((event.key === "Tab" || event.key === "Enter") && !event.shiftKey) {
402 event.preventDefault();
403 complete(match.options[active] ?? match.options[0]!);
404 return true;
405 }
406 if (event.key === "Escape") {
407 event.preventDefault();
408 setMatch(null);
409 return true;
410 }
411 }
412 if (event.key === "Escape" && editing) {
413 event.preventDefault();
414 onCancel?.();
415 return true;
416 }
417 if (event.key !== "Enter" || event.isComposing) return false;
418 // Ctrl+Enter (⌘+Enter) sends from anywhere, a list or code too.
419 if (event.ctrlKey || event.metaKey) {
420 event.preventDefault();
421 send();
422 return true;
423 }
424 const inCode = inside(editor, "codeBlock");
425 const inList = inside(editor, "listItem");
426 if (inCode) {
427 // A new line in the block; three Enters in a row leave it.
428 if (event.shiftKey) {
429 editor
430 .chain()
431 .command(({ tr }) => {
432 tr.insertText("\n");
433 return true;
434 })
435 .run();
436 return true;
437 }
438 return false;
439 }
440 if (inList) {
441 // A new item, or out of the list from an empty one.
442 return event.shiftKey ? editor.commands.splitListItem("listItem") || false : false;
443 }
444 if (event.shiftKey) {
445 // A new line: the next paragraph, so lists and quotes can start on it.
446 return editor.commands.splitBlock();
447 }
448 // ``` and a language, then Enter: a code block.
449 const fence = /^```([\w+#.-]*)$/.exec(textBox(editor).value);
450 if (fence) {
451 const start = editor.state.selection.$from.start();
452 editor
453 .chain()
454 .deleteRange({ from: start, to: start + fence[0].length })
455 .setCodeBlock(fence[1] ? { language: fence[1] } : undefined)
456 .run();
457 return true;
458 }
459 event.preventDefault();
460 send();
461 return true;
462 };
463
464 // The `@` list is the text box's: say so to a screen reader.
465 useEffect(() => {
466 const dom = editor?.view.dom;
467 if (!dom) return;
468 if (match) {
469 dom.setAttribute("aria-expanded", "true");
470 dom.setAttribute("aria-controls", list);
471 dom.setAttribute("aria-activedescendant", `${list}-${active}`);
472 } else {
473 dom.removeAttribute("aria-expanded");
474 dom.removeAttribute("aria-controls");
475 dom.removeAttribute("aria-activedescendant");
476 }
477 }, [editor, match, active, list]);
478
479 const applyLink = (text: string, href: string) => {
480 if (!editor) return false;
481 const address = linkAddress(href);
482 if (!address) return false;
483 const { from, to } = editor.state.selection;
484 if (from === to) {
485 const words = text.trim() || address;
486 editor
487 .chain()
488 .focus()
489 .insertContent({ type: "text", text: words, marks: [{ type: "link", attrs: { href: address } }] })
490 .unsetMark("link")
491 .insertContent({ type: "text", text: " " })
492 .run();
493 } else {
494 editor.chain().focus().extendMarkRange("link").setLink({ href: address }).run();
495 }
496 return true;
497 };
498
499 const tool = (label: string, icon: ReactNode, on: boolean | undefined, run: () => void) => (
500 <Hint label={`${label} (${keysOf(label)})`}>
501 <button
502 type="button"
503 aria-label={label}
504 aria-pressed={!!on}
505 disabled={disabled || !editor}
506 onMouseDown={(event) => event.preventDefault()}
507 onClick={run}
508 className={`flex size-8 shrink-0 items-center justify-center rounded-md transition-colors sm:size-7 ${
509 on ? "bg-accent/15 text-accent" : "text-faint hover:bg-raised hover:text-fg"
510 }`}
511 >
512 {icon}
513 </button>
514 </Hint>
515 );
516
517 const chain = () => editor!.chain().focus();
518
519 return (
520 <div className="relative">
521 {emoji.menu}
522 {match && (
523 <ul
524 id={list}
525 role="listbox"
526 aria-label="Mention someone"
527 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"
528 >
529 {match.options.map((person, index) => (
530 <li
531 key={`${person.kind}:${person.name}`}
532 id={`${list}-${index}`}
533 role="option"
534 aria-selected={index === active}
535 onMouseDown={(event) => event.preventDefault()}
536 onMouseEnter={() => setActive(index)}
537 onClick={() => complete(person)}
538 className={`flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 ${index === active ? "bg-line" : ""}`}
539 >
540 <MemberAvatar member={person} size={22} />
541 <span className="min-w-0 grow truncate text-sm">
542 <span className="font-medium text-fg">{person.display_name}</span>
543 <span className="ml-1.5 text-faint">@{shownHandle(person)}</span>
544 </span>
545 {person.kind === "agent" && <AgentPill />}
546 </li>
547 ))}
548 </ul>
549 )}
550 {link && (
551 <LinkForm
552 initial={link}
553 onApply={(text, href) => {
554 if (!applyLink(text, href)) return false;
555 setLink(null);
556 return true;
557 }}
558 onRemove={
559 link.existing
560 ? () => {
561 editor?.chain().focus().extendMarkRange("link").unsetLink().run();
562 setLink(null);
563 }
564 : undefined
565 }
566 onClose={() => {
567 setLink(null);
568 editor?.commands.focus();
569 }}
570 />
571 )}
572 <div
573 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" : ""}`}
574 >
575 {toolbar && (
576 <div
577 role="toolbar"
578 aria-label="Formatting"
579 className="flex items-center gap-0.5 overflow-x-auto border-b border-line px-1.5 py-1 [scrollbar-width:none]"
580 >
581 {tool("Bold", <Bold size={15} strokeWidth={2.4} />, state?.bold, () => chain().toggleBold().run())}
582 {tool("Italic", <Italic size={15} />, state?.italic, () => chain().toggleItalic().run())}
583 {tool("Strikethrough", <Strikethrough size={15} />, state?.strike, () => chain().toggleStrike().run())}
584 <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line" />
585 {tool("Link", <LinkIcon size={15} />, state?.link, () => openLink.current())}
586 <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line" />
587 {tool("Bulleted list", <List size={15} />, state?.bulletList, () => chain().toggleBulletList().run())}
588 {tool("Numbered list", <ListOrdered size={15} />, state?.orderedList, () => chain().toggleOrderedList().run())}
589 {tool("Quote", <TextQuote size={15} />, state?.blockquote, () => chain().toggleBlockquote().run())}
590 <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line" />
591 {tool("Code", <Code size={15} />, state?.code, () => chain().toggleCode().run())}
592 {tool("Code block", <SquareCode size={15} />, state?.codeBlock, () => chain().toggleCodeBlock().run())}
593 <ShortcutList />
594 </div>
595 )}
596 <div
597 className={`overflow-y-auto px-3.5 text-[0.9375rem] leading-6 text-fg [scrollbar-width:thin] ${compact ? "pt-2.5 pb-1" : "pt-3 pb-1.5"}`}
598 style={{ maxHeight: MAX_HEIGHT }}
599 onClick={(event) => {
600 // The whole box takes focus, not only its first line.
601 if (event.target === event.currentTarget) editor?.commands.focus("end");
602 }}
603 >
604 {editor ? (
605 <EditorContent editor={editor} />
606 ) : (
607 <p aria-hidden="true" className="text-faint">
608 {placeholder}
609 </p>
610 )}
611 </div>
612 <div className="flex items-center gap-1 px-2 pb-2">
613 <Hint label={toolbar ? "Hide formatting" : "Show formatting"}>
614 <button
615 type="button"
616 aria-label={toolbar ? "Hide formatting" : "Show formatting"}
617 aria-pressed={toolbar}
618 onMouseDown={(event) => event.preventDefault()}
619 onClick={() => setToolbar(!toolbar)}
620 className={`flex size-7 items-center justify-center rounded-md transition-colors ${toolbar ? "bg-raised text-fg" : "text-faint hover:bg-raised hover:text-fg"}`}
621 >
622 <Type size={15} />
623 </button>
624 </Hint>
625 <Hint label="Mention someone">
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.626 <Button
Merge main into Artifacts Phase 2627 type="button"
628 aria-label="Mention someone"
629 disabled={disabled || !editor}
630 onMouseDown={(event) => event.preventDefault()}
631 onClick={() => {
632 if (!editor) return;
633 const text = textBox(editor);
634 const before = text.value.slice(0, text.selectionStart);
635 editor
636 .chain()
637 .focus()
638 .insertContent({ type: "text", text: before && !/\s$/.test(before) ? " @" : "@" })
639 .run();
640 look(editor);
641 }}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.642 variant="ghost"
643 size="icon-xs"
644 className="text-faint"
Merge main into Artifacts Phase 2645 >
646 <AtSign size={15} />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.647 </Button>
Merge main into Artifacts Phase 2648 </Hint>
649 <EmojiPickerPopover side="top" align="start" onPick={(picked) => editor?.chain().focus().insertContent({ type: "text", text: picked }).run()}>
650 <Hint label="Add an emoji">
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.651 <Button
Merge main into Artifacts Phase 2652 type="button"
653 aria-label="Add an emoji"
654 disabled={disabled || !editor}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.655 variant="ghost"
656 size="icon-xs"
657 className="text-faint"
Merge main into Artifacts Phase 2658 >
659 <Smile size={15} />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.660 </Button>
Merge main into Artifacts Phase 2661 </Hint>
662 </EmojiPickerPopover>
663 {editing ? (
664 <>
665 <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
666 <kbd className="font-sans">Esc</kbd> to cancel · <kbd className="font-sans">Enter</kbd> to save
667 </span>
668 <span className="ml-auto flex items-center gap-1.5">
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.669 <Button
Merge main into Artifacts Phase 2670 type="button"
671 onClick={onCancel}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.672 variant="outline"
673 size="sm"
674 className="rounded-lg px-3 text-fg-soft hover:bg-raised"
Merge main into Artifacts Phase 2675 >
676 Cancel
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.677 </Button>
678 <Button
Merge main into Artifacts Phase 2679 type="button"
680 disabled={disabled || !editor || state?.empty}
681 onClick={send}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.682 variant="accent"
683 size="sm"
684 className="rounded-lg px-3 disabled:bg-line disabled:text-faint disabled:opacity-100"
Merge main into Artifacts Phase 2685 >
686 Save
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.687 </Button>
Merge main into Artifacts Phase 2688 </span>
689 </>
690 ) : (
691 <>
692 <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
693 <kbd className="font-sans">Enter</kbd> to send · <kbd className="font-sans">Shift+Enter</kbd> for a new line
694 </span>
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.695 <Button
Merge main into Artifacts Phase 2696 type="button"
697 aria-label="Send"
698 disabled={disabled || !editor || state?.empty !== false}
699 onMouseDown={(event) => event.preventDefault()}
700 onClick={send}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.701 variant="accent"
702 size="icon-sm"
703 className="ml-auto rounded-lg transition-[background-color,opacity] disabled:bg-line disabled:text-faint disabled:opacity-100"
Merge main into Artifacts Phase 2704 >
705 <ArrowUp size={16} strokeWidth={2.4} />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.706 </Button>
Merge main into Artifacts Phase 2707 </>
708 )}
709 </div>
710 </div>
711 </div>
712 );
713}
714
715/** The bar's last button: every shortcut, and what Markdown typed becomes. */
716function ShortcutList() {
717 return (
718 <Popover>
719 <Hint label="Formatting shortcuts">
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.720 <PopoverTrigger asChild>
721 <Button variant="ghost" size="icon-sm" aria-label="Formatting shortcuts" onMouseDown={(event) => event.preventDefault()} className="ml-auto text-faint sm:size-7">
722 <Keyboard size={15} />
723 </Button>
Merge main into Artifacts Phase 2724 </PopoverTrigger>
725 </Hint>
726 <PopoverContent side="top" align="end" className="w-80 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
727 <p className="mb-2 text-xs font-semibold text-fg">Formatting shortcuts</p>
728 <dl className="space-y-1 text-[0.8125rem]">
729 {SHORTCUTS.map((shortcut) => (
730 <div key={shortcut.label} className="flex items-center justify-between gap-3">
731 <dt className="text-fg-soft">{shortcut.label}</dt>
732 <dd className="flex gap-1">
733 {shortcut.keys.map((key) => (
734 <kbd key={key} className="rounded border border-line-strong bg-surface px-1.5 font-sans text-[0.6875rem] text-muted">
735 {key}
736 </kbd>
737 ))}
738 </dd>
739 </div>
740 ))}
741 </dl>
742 <p className="mt-3 mb-1.5 text-xs font-semibold text-fg">Or type Markdown</p>
743 <p className="font-mono text-[0.75rem] leading-relaxed text-muted">
744 *bold* _italic_ ~strike~ `code`
745 <br />
746 - list · 1. list · &gt; quote
747 <br />
748 ``` code block
749 </p>
750 <p className="mt-3 text-[0.75rem] text-faint">
751 <kbd className="font-sans">{MOD}+Enter</kbd> sends from a list or code block.
752 </p>
753 </PopoverContent>
754 </Popover>
755 );
756}
757
758/** Adding or changing a link: its text when nothing is selected, and where it goes. */
759function LinkForm({
760 initial,
761 onApply,
762 onRemove,
763 onClose,
764}: {
765 initial: { text: string; href: string; existing: boolean };
766 onApply: (text: string, href: string) => boolean;
767 onRemove?: () => void;
768 onClose: () => void;
769}) {
770 const [text, setText] = useState(initial.text);
771 const [href, setHref] = useState(initial.href);
772 const [wrong, setWrong] = useState(false);
773 const askText = !initial.text;
774 const id = useId();
775 const keys = (event: ReactKeyboardEvent) => {
776 if (event.key === "Escape") {
777 event.preventDefault();
778 event.stopPropagation();
779 onClose();
780 }
781 };
782 return (
783 <form
784 aria-label={initial.existing ? "Edit link" : "Add a link"}
785 onSubmit={(event) => {
786 event.preventDefault();
787 setWrong(!onApply(text, href));
788 }}
789 onKeyDown={keys}
790 className="absolute bottom-full left-0 z-20 mb-2 w-full max-w-sm space-y-2 rounded-lg border border-line-strong bg-raised p-3 shadow-xl shadow-black/40"
791 >
792 {askText && (
793 <div className="space-y-1">
794 <label htmlFor={`${id}-text`} className="text-xs font-medium text-muted">
795 Text
796 </label>
797 <input
798 id={`${id}-text`}
799 autoFocus
800 value={text}
801 onChange={(event) => setText(event.target.value)}
802 placeholder="What the link says"
803 autoComplete="off"
804 className="h-8 w-full rounded-md border border-line bg-bg px-2.5 text-sm text-fg outline-none placeholder:text-faint focus:border-accent/40"
805 />
806 </div>
807 )}
808 <div className="space-y-1">
809 <label htmlFor={`${id}-href`} className="text-xs font-medium text-muted">
810 Link
811 </label>
812 <input
813 id={`${id}-href`}
814 autoFocus={!askText}
815 value={href}
816 onChange={(event) => {
817 setHref(event.target.value);
818 setWrong(false);
819 }}
820 placeholder="https://"
821 inputMode="url"
822 autoComplete="off"
823 aria-invalid={wrong}
824 aria-describedby={wrong ? `${id}-wrong` : undefined}
825 className="h-8 w-full rounded-md border border-line bg-bg px-2.5 text-sm text-fg outline-none placeholder:text-faint focus:border-accent/40 aria-invalid:border-danger/60"
826 />
827 {wrong && (
828 <p id={`${id}-wrong`} className="text-xs text-danger">
829 A link goes to a web address (https://…), an email address or a page on g1t.
830 </p>
831 )}
832 </div>
833 <div className="flex items-center gap-1.5 pt-0.5">
834 {onRemove && (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.835 <Button type="button" onClick={onRemove} variant="ghost" size="sm" className="flex px-2 hover:bg-line font-normal">
Merge main into Artifacts Phase 2836 <Unlink size={14} />
837 Remove link
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.838 </Button>
Merge main into Artifacts Phase 2839 )}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.840 <Button type="button" onClick={onClose} variant="ghost" size="sm" className="ml-auto px-3 hover:bg-line font-normal">
Merge main into Artifacts Phase 2841 Cancel
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.842 </Button>
843 <Button type="submit" disabled={!href.trim()} variant="accent" size="sm" className="px-3 disabled:bg-line disabled:text-faint disabled:opacity-100">
Merge main into Artifacts Phase 2844 {initial.existing ? "Save" : "Add link"}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.845 </Button>
Merge main into Artifacts Phase 2846 </div>
847 </form>
848 );
849}

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