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 2 | 1 | import { Extension, markInputRule, type Editor } from "@tiptap/core"; |
| 2 | import { Slice } from "@tiptap/pm/model"; | |
| 3 | import { Placeholder } from "@tiptap/extensions"; | |
| 4 | import { EditorContent, useEditor, useEditorState } from "@tiptap/react"; | |
| 5 | import StarterKit from "@tiptap/starter-kit"; | |
| 6 | import { | |
| 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"; | |
| 23 | import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject, useEffect, useId, useMemo, useRef, useState } from "react"; | |
| 24 | ||
| 25 | import { 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. | 26 | import { Button } from "../ui/button"; |
| Merge main into Artifacts Phase 2 | 27 | import { Hint } from "../ui/hint"; |
| 28 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 29 | // `:shortcode` completion and the emoji picker (components/emoji). | |
| 30 | import { useEmojiAutocomplete } from "../emoji/autocomplete"; | |
| 31 | import { EmojiPickerPopover } from "../emoji/picker"; | |
| 32 | import { type MentionQuery, type Mentionable, mentionQuery, safeHref, shownHandle } from "../../lib/chat"; | |
| 33 | import { docToMarkdown, markdownToDoc } from "../../lib/chat-compose"; | |
| 34 | ||
| 35 | /** How tall the box grows before it scrolls. */ | |
| 36 | const MAX_HEIGHT = 240; | |
| 37 | ||
| 38 | export 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~~`. */ | |
| 57 | const 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). */ | |
| 73 | function 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. */ | |
| 92 | function 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 | */ | |
| 103 | type TextBox = { value: string; selectionStart: number; selectionEnd: number; focus: () => void; setSelectionRange: (from: number, to: number) => void }; | |
| 104 | ||
| 105 | function 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. */ | |
| 127 | function 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. */ | |
| 143 | function 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 | ||
| 151 | const MOD = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? "⌘" : "Ctrl"; | |
| 152 | ||
| 153 | /** Every formatting shortcut, for the hints and the list. */ | |
| 154 | const 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 | ||
| 166 | const 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. */ | |
| 169 | function 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 | */ | |
| 206 | export 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 2 | 627 | 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 2 | 645 | > |
| 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 2 | 648 | </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 2 | 652 | 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 2 | 658 | > |
| 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 2 | 661 | </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 2 | 670 | 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 2 | 675 | > |
| 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 2 | 679 | 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 2 | 685 | > |
| 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 2 | 688 | </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 2 | 696 | 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 2 | 704 | > |
| 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 2 | 707 | </> |
| 708 | )} | |
| 709 | </div> | |
| 710 | </div> | |
| 711 | </div> | |
| 712 | ); | |
| 713 | } | |
| 714 | ||
| 715 | /** The bar's last button: every shortcut, and what Markdown typed becomes. */ | |
| 716 | function 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 2 | 724 | </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 · > 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. */ | |
| 759 | function 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 2 | 836 | <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 2 | 839 | )} |
| 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 2 | 841 | 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 2 | 844 | {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 2 | 846 | </div> |
| 847 | </form> | |
| 848 | ); | |
| 849 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.