Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
848 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
The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.25import { 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. `@`
The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.202 * offers the workspace's people and `:` its emoji (Up and Down
Merge main into Artifacts Phase 2203 * 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 </li>
546 ))}
547 </ul>
548 )}
549 {link && (
550 <LinkForm
551 initial={link}
552 onApply={(text, href) => {
553 if (!applyLink(text, href)) return false;
554 setLink(null);
555 return true;
556 }}
557 onRemove={
558 link.existing
559 ? () => {
560 editor?.chain().focus().extendMarkRange("link").unsetLink().run();
561 setLink(null);
562 }
563 : undefined
564 }
565 onClose={() => {
566 setLink(null);
567 editor?.commands.focus();
568 }}
569 />
570 )}
571 <div
572 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" : ""}`}
573 >
574 {toolbar && (
575 <div
576 role="toolbar"
577 aria-label="Formatting"
578 className="flex items-center gap-0.5 overflow-x-auto border-b border-line px-1.5 py-1 [scrollbar-width:none]"
579 >
580 {tool("Bold", <Bold size={15} strokeWidth={2.4} />, state?.bold, () => chain().toggleBold().run())}
581 {tool("Italic", <Italic size={15} />, state?.italic, () => chain().toggleItalic().run())}
582 {tool("Strikethrough", <Strikethrough size={15} />, state?.strike, () => chain().toggleStrike().run())}
583 <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line" />
584 {tool("Link", <LinkIcon size={15} />, state?.link, () => openLink.current())}
585 <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line" />
586 {tool("Bulleted list", <List size={15} />, state?.bulletList, () => chain().toggleBulletList().run())}
587 {tool("Numbered list", <ListOrdered size={15} />, state?.orderedList, () => chain().toggleOrderedList().run())}
588 {tool("Quote", <TextQuote size={15} />, state?.blockquote, () => chain().toggleBlockquote().run())}
589 <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line" />
590 {tool("Code", <Code size={15} />, state?.code, () => chain().toggleCode().run())}
591 {tool("Code block", <SquareCode size={15} />, state?.codeBlock, () => chain().toggleCodeBlock().run())}
592 <ShortcutList />
593 </div>
594 )}
595 <div
596 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"}`}
597 style={{ maxHeight: MAX_HEIGHT }}
598 onClick={(event) => {
599 // The whole box takes focus, not only its first line.
600 if (event.target === event.currentTarget) editor?.commands.focus("end");
601 }}
602 >
603 {editor ? (
604 <EditorContent editor={editor} />
605 ) : (
606 <p aria-hidden="true" className="text-faint">
607 {placeholder}
608 </p>
609 )}
610 </div>
611 <div className="flex items-center gap-1 px-2 pb-2">
612 <Hint label={toolbar ? "Hide formatting" : "Show formatting"}>
613 <button
614 type="button"
615 aria-label={toolbar ? "Hide formatting" : "Show formatting"}
616 aria-pressed={toolbar}
617 onMouseDown={(event) => event.preventDefault()}
618 onClick={() => setToolbar(!toolbar)}
619 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"}`}
620 >
621 <Type size={15} />
622 </button>
623 </Hint>
624 <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.625 <Button
Merge main into Artifacts Phase 2626 type="button"
627 aria-label="Mention someone"
628 disabled={disabled || !editor}
629 onMouseDown={(event) => event.preventDefault()}
630 onClick={() => {
631 if (!editor) return;
632 const text = textBox(editor);
633 const before = text.value.slice(0, text.selectionStart);
634 editor
635 .chain()
636 .focus()
637 .insertContent({ type: "text", text: before && !/\s$/.test(before) ? " @" : "@" })
638 .run();
639 look(editor);
640 }}
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.641 variant="ghost"
642 size="icon-xs"
643 className="text-faint"
Merge main into Artifacts Phase 2644 >
645 <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.646 </Button>
Merge main into Artifacts Phase 2647 </Hint>
648 <EmojiPickerPopover side="top" align="start" onPick={(picked) => editor?.chain().focus().insertContent({ type: "text", text: picked }).run()}>
649 <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.650 <Button
Merge main into Artifacts Phase 2651 type="button"
652 aria-label="Add an emoji"
653 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.654 variant="ghost"
655 size="icon-xs"
656 className="text-faint"
Merge main into Artifacts Phase 2657 >
658 <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.659 </Button>
Merge main into Artifacts Phase 2660 </Hint>
661 </EmojiPickerPopover>
662 {editing ? (
663 <>
664 <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
665 <kbd className="font-sans">Esc</kbd> to cancel · <kbd className="font-sans">Enter</kbd> to save
666 </span>
667 <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.668 <Button
Merge main into Artifacts Phase 2669 type="button"
670 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.671 variant="outline"
672 size="sm"
673 className="rounded-lg px-3 text-fg-soft hover:bg-raised"
Merge main into Artifacts Phase 2674 >
675 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.676 </Button>
677 <Button
Merge main into Artifacts Phase 2678 type="button"
679 disabled={disabled || !editor || state?.empty}
680 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.681 variant="accent"
682 size="sm"
683 className="rounded-lg px-3 disabled:bg-line disabled:text-faint disabled:opacity-100"
Merge main into Artifacts Phase 2684 >
685 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.686 </Button>
Merge main into Artifacts Phase 2687 </span>
688 </>
689 ) : (
690 <>
691 <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
692 <kbd className="font-sans">Enter</kbd> to send · <kbd className="font-sans">Shift+Enter</kbd> for a new line
693 </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.694 <Button
Merge main into Artifacts Phase 2695 type="button"
696 aria-label="Send"
697 disabled={disabled || !editor || state?.empty !== false}
698 onMouseDown={(event) => event.preventDefault()}
699 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.700 variant="accent"
701 size="icon-sm"
702 className="ml-auto rounded-lg transition-[background-color,opacity] disabled:bg-line disabled:text-faint disabled:opacity-100"
Merge main into Artifacts Phase 2703 >
704 <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.705 </Button>
Merge main into Artifacts Phase 2706 </>
707 )}
708 </div>
709 </div>
710 </div>
711 );
712}
713
714/** The bar's last button: every shortcut, and what Markdown typed becomes. */
715function ShortcutList() {
716 return (
717 <Popover>
718 <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.719 <PopoverTrigger asChild>
720 <Button variant="ghost" size="icon-sm" aria-label="Formatting shortcuts" onMouseDown={(event) => event.preventDefault()} className="ml-auto text-faint sm:size-7">
721 <Keyboard size={15} />
722 </Button>
Merge main into Artifacts Phase 2723 </PopoverTrigger>
724 </Hint>
725 <PopoverContent side="top" align="end" className="w-80 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
726 <p className="mb-2 text-xs font-semibold text-fg">Formatting shortcuts</p>
727 <dl className="space-y-1 text-[0.8125rem]">
728 {SHORTCUTS.map((shortcut) => (
729 <div key={shortcut.label} className="flex items-center justify-between gap-3">
730 <dt className="text-fg-soft">{shortcut.label}</dt>
731 <dd className="flex gap-1">
732 {shortcut.keys.map((key) => (
733 <kbd key={key} className="rounded border border-line-strong bg-surface px-1.5 font-sans text-[0.6875rem] text-muted">
734 {key}
735 </kbd>
736 ))}
737 </dd>
738 </div>
739 ))}
740 </dl>
741 <p className="mt-3 mb-1.5 text-xs font-semibold text-fg">Or type Markdown</p>
742 <p className="font-mono text-[0.75rem] leading-relaxed text-muted">
743 *bold* _italic_ ~strike~ `code`
744 <br />
745 - list · 1. list · &gt; quote
746 <br />
747 ``` code block
748 </p>
749 <p className="mt-3 text-[0.75rem] text-faint">
750 <kbd className="font-sans">{MOD}+Enter</kbd> sends from a list or code block.
751 </p>
752 </PopoverContent>
753 </Popover>
754 );
755}
756
757/** Adding or changing a link: its text when nothing is selected, and where it goes. */
758function LinkForm({
759 initial,
760 onApply,
761 onRemove,
762 onClose,
763}: {
764 initial: { text: string; href: string; existing: boolean };
765 onApply: (text: string, href: string) => boolean;
766 onRemove?: () => void;
767 onClose: () => void;
768}) {
769 const [text, setText] = useState(initial.text);
770 const [href, setHref] = useState(initial.href);
771 const [wrong, setWrong] = useState(false);
772 const askText = !initial.text;
773 const id = useId();
774 const keys = (event: ReactKeyboardEvent) => {
775 if (event.key === "Escape") {
776 event.preventDefault();
777 event.stopPropagation();
778 onClose();
779 }
780 };
781 return (
782 <form
783 aria-label={initial.existing ? "Edit link" : "Add a link"}
784 onSubmit={(event) => {
785 event.preventDefault();
786 setWrong(!onApply(text, href));
787 }}
788 onKeyDown={keys}
789 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"
790 >
791 {askText && (
792 <div className="space-y-1">
793 <label htmlFor={`${id}-text`} className="text-xs font-medium text-muted">
794 Text
795 </label>
796 <input
797 id={`${id}-text`}
798 autoFocus
799 value={text}
800 onChange={(event) => setText(event.target.value)}
801 placeholder="What the link says"
802 autoComplete="off"
803 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"
804 />
805 </div>
806 )}
807 <div className="space-y-1">
808 <label htmlFor={`${id}-href`} className="text-xs font-medium text-muted">
809 Link
810 </label>
811 <input
812 id={`${id}-href`}
813 autoFocus={!askText}
814 value={href}
815 onChange={(event) => {
816 setHref(event.target.value);
817 setWrong(false);
818 }}
819 placeholder="https://"
820 inputMode="url"
821 autoComplete="off"
822 aria-invalid={wrong}
823 aria-describedby={wrong ? `${id}-wrong` : undefined}
824 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"
825 />
826 {wrong && (
827 <p id={`${id}-wrong`} className="text-xs text-danger">
828 A link goes to a web address (https://…), an email address or a page on g1t.
829 </p>
830 )}
831 </div>
832 <div className="flex items-center gap-1.5 pt-0.5">
833 {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.834 <Button type="button" onClick={onRemove} variant="ghost" size="sm" className="flex px-2 hover:bg-line font-normal">
Merge main into Artifacts Phase 2835 <Unlink size={14} />
836 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.837 </Button>
Merge main into Artifacts Phase 2838 )}
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.839 <Button type="button" onClick={onClose} variant="ghost" size="sm" className="ml-auto px-3 hover:bg-line font-normal">
Merge main into Artifacts Phase 2840 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.841 </Button>
842 <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 2843 {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.844 </Button>
Merge main into Artifacts Phase 2845 </div>
846 </form>
847 );
848}

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