Skip to content

Commit

The chat composer formats as you write: a bar for bold, italic, strikethrough, links, lists, quotes, code and code blocks that can be shown or hidden, shortcuts listed under its keyboard button (Ctrl or Cmd with B, I, Shift+X, K, Shift+C, Alt+Shift+C, Shift+7, 8 and 9), and Markdown typed inline (*bold*, _italic_, ~strike~, `code`, ``` blocks, - and 1. lists, > quotes) turning into its formatting. Enter sends, Shift+Enter starts a line, Enter adds a list item or a code line where it should, Ctrl+Enter sends from anywhere, pasting a link over words links them, and plain text pasted in is read as Markdown. Mentions, emoji completion, drafts and typing work as before, messages are still sent and kept as Markdown, the editor loads only with the composer, and your own messages are edited in place with the same composer on a computer (Edit message in the message's menu) and in the sheet on a phone.

syntaqxcommitted Parentbdeb58cBrowse files
9 files+1585−2890/9 viewed
+44−0
793793 line-height: 1.65;
794794 }
795795
796+/* The chat composer (components/chat/editor.tsx): formatting as messages show it. */
797+.chat-editor {
798+ white-space: pre-wrap;
799+ word-break: break-word;
800+ min-height: 1.5rem;
801+}
802+.chat-editor p.is-editor-empty:first-child::before {
803+ content: attr(data-placeholder);
804+ float: left;
805+ height: 0;
806+ pointer-events: none;
807+ color: var(--color-faint);
808+}
809+.chat-editor strong {
810+ @apply font-semibold text-fg;
811+}
812+.chat-editor s {
813+ @apply text-muted;
814+}
815+.chat-editor a {
816+ @apply text-accent underline underline-offset-2;
817+}
818+.chat-editor code {
819+ @apply rounded-[4px] border border-line bg-raised px-1 py-px font-mono text-[0.84em] text-fg-soft;
820+}
821+.chat-editor pre {
822+ @apply my-1 rounded-md border border-line bg-bg px-3 py-2 font-mono text-[0.8125rem] leading-relaxed text-fg-soft;
823+}
824+.chat-editor pre code {
825+ @apply border-0 bg-transparent p-0 text-[1em];
826+}
827+.chat-editor ul {
828+ @apply list-disc pl-5 marker:text-faint;
829+}
830+.chat-editor ol {
831+ @apply list-decimal pl-5 marker:text-faint;
832+}
833+.chat-editor li > p {
834+ @apply pl-0.5;
835+}
836+.chat-editor blockquote {
837+ @apply my-0.5 border-l-2 border-line-strong pl-3 text-muted;
838+}
839+
+45−37
167167 const [actions, setActions] = useState<ShownMessage | null>(null);
168168 // Someone's profile, opened from their card, beside the conversation.
169169 const [profile, setProfile] = useState<string | null>(null);
170− const [editing, setEditing] = useState<{ id: string; body: string } | null>(null);
170+ // A message being edited: in its place on a computer (`inline`), in a sheet on a phone.
171+ const [editing, setEditing] = useState<{ id: string; body: string; inline?: boolean } | null>(null);
171172 const scroller = useRef<HTMLDivElement>(null);
172173 const stick = useRef(true);
173174
555556 };
556557
557558 const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]);
559+ const mine = (message: ShownMessage) => message.author.kind === "user" && message.author.id === me?.id && !message.pending && !message.deleted_at;
560+ // Editing uses the composer, started from the message's Markdown.
561+ const editor = (target: { id: string; body: string }) => (
562+ <Composer
563+ initial={target.body}
564+ placeholder="Edit message"
565+ people={people}
566+ autoFocus
567+ compact
568+ onCancel={() => setEditing(null)}
569+ onSend={async (body) => {
570+ setEditing(null);
571+ if (body === target.body) return;
572+ // Shown edited at once; put back if the service refuses.
573+ const before = messages.find((m) => m.id === target.id);
574+ setMessages((now) => now.map((m) => (m.id === target.id ? { ...m, body, edited_at: new Date().toISOString() } : m)));
575+ const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: target.id, body });
576+ if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value]));
577+ else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m)));
578+ }}
579+ />
580+ );
558581 const typers = [...typing.values()].map((t) => t.member);
559582 const name = channel.name ?? "";
560583 const archived = channel.kind === "channel" && !!channel.archived_at;
637660 onRetry={() => retry(row.message)}
638661 onLongPress={() => setActions(row.message)}
639662 onCopyLink={() => copyThreadLink(row.message.id)}
663+ onEdit={mine(row.message) ? () => setEditing({ id: row.message.id, body: row.message.body, inline: true }) : undefined}
664+ editor={editing?.inline && editing.id === row.message.id ? editor(editing) : undefined}
640665 onWriteUp={joined && !archived ? () => setWriteUp(row.message.id) : undefined}
641666 threadOpen={row.message.id === threadId}
642667 />
750775 link={writeUp && typeof window !== "undefined" ? linkToThread(writeUp) : ""}
751776 onSend={askForWriteUp}
752777 />
753− <BottomSheet open={editing != null} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
754− {editing && (
755− <form
756− className="space-y-3 px-1 pt-1"
757− onSubmit={async (event) => {
758− event.preventDefault();
759− const body = editing.body.trim();
760− if (!body) return;
761− // Shown edited at once; put back if the service refuses.
762− const before = messages.find((m) => m.id === editing.id);
763− setMessages((now) => now.map((m) => (m.id === editing.id ? { ...m, body, edited_at: new Date().toISOString() } : m)));
764− setEditing(null);
765− const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: editing.id, body });
766− if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value]));
767− else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m)));
768− }}
769− >
778+ <BottomSheet open={editing != null && !editing.inline} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
779+ {editing && !editing.inline && (
780+ <div className="space-y-3 px-1 pt-1">
770781 <h2 className="px-2 text-base font-semibold">Edit message</h2>
771− <textarea
772− autoFocus
773− value={editing.body}
774− onChange={(event) => setEditing({ ...editing, body: event.target.value })}
775− rows={4}
776− className="block w-full resize-none rounded-xl border border-line-strong bg-bg px-3.5 py-3 text-base text-fg outline-none focus:border-accent/40"
777− />
778− <div className="flex gap-2">
779− <button type="button" onClick={() => setEditing(null)} className="h-11 grow rounded-xl border border-line text-[0.9375rem] font-medium active:bg-raised">
780− Cancel
781− </button>
782− <button type="submit" className="h-11 grow rounded-xl bg-accent text-[0.9375rem] font-medium text-bg active:bg-accent-hover">
783− Save
784− </button>
785− </div>
786− </form>
782+ {editor(editing)}
783+ </div>
787784 )}
788785 </BottomSheet>
789786 </div>
12191216 onWriteUp,
12201217 threadOpen,
12211218 inThread,
1219+ onEdit,
1220+ editor,
12221221 }: {
1222+ /** Your own message: "Edit message" in the "⋯", and the composer in its place while editing. */
1223+ onEdit?: () => void;
1224+ editor?: ReactNode;
12231225 /** A phone: a long press opens the message's actions. */
12241226 onLongPress?: () => void;
12251227 /** The hover toolbar's "⋯": copy the link to its thread, write it up in Docs. */
12761278 </div>
12771279 )}
12781280 <div className={message.pending && !message.failed ? "opacity-60" : undefined}>
1279− {message.body && <MessageText body={message.body} context={context} />}
1281+ {editor ? <div className="py-1">{editor}</div> : message.body && <MessageText body={message.body} context={context} />}
12801282 {message.card && (
12811283 <CardBox
12821284 card={message.card}
13271329 <MessageSquareText size={15} />
13281330 </button>
13291331 </Hint>
1330− {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} />}
1332+ {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} onEdit={onEdit} />}
13311333 </div>
13321334 )}
13331335 </article>
13351337 }
13361338
13371339 /** A message's "⋯" in its hover toolbar. */
1338−function MoreActions({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1340+function MoreActions({ onCopyLink, onWriteUp, onEdit }: { onCopyLink: () => void; onWriteUp?: () => void; onEdit?: () => void }) {
13391341 return (
13401342 <DropdownMenu>
13411343 <Hint label="More actions">
13471349 </DropdownMenuTrigger>
13481350 </Hint>
13491351 <DropdownMenuContent align="end">
1352+ {onEdit && (
1353+ <DropdownMenuItem onSelect={onEdit}>
1354+ <Pencil />
1355+ Edit message
1356+ </DropdownMenuItem>
1357+ )}
13501358 <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} />
13511359 </DropdownMenuContent>
13521360 </DropdownMenu>
+24−250
1−import { ArrowUp, AtSign, Smile } from "lucide-react";
2−import { type KeyboardEvent, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
1+import { ArrowUp } from "lucide-react";
2+import { Suspense, lazy } from "react";
33
4−import { AgentPill, MemberAvatar } from "./marks";
5−import { Hint } from "../ui/hint";
6−// `:shortcode` completion and the emoji picker (components/emoji).
7−import { useEmojiAutocomplete } from "../emoji/autocomplete";
8−import { EmojiPickerPopover } from "../emoji/picker";
9−import { type MentionQuery, type Mentionable, mentionQuery, shownHandle } from "../../lib/chat";
4+import type { ComposerProps } from "./editor";
105
11−/** How tall the box grows before it scrolls. */
12−const MAX_HEIGHT = 240;
6+// The editor (components/chat/editor.tsx) is loaded with the first composer,
7+// so pages without one never fetch it.
8+const ChatEditor = lazy(() => import("./editor"));
139
1410 /**
15− * Where a message is written. Enter sends, Shift+Enter starts a new line;
16− * `@` offers the workspace's people and agents (Up and Down to move, Tab or
17− * Enter to take one, Escape to dismiss). What is typed is kept per
18− * conversation while the tab is open, so moving away loses nothing.
11+ * Where a message is written: the formatting editor, with what it looks
12+ * like for the moment it takes to load. See components/chat/editor.tsx.
1913 */
20−export function Composer({
21− draftKey,
22− placeholder,
23− people,
24− onSend,
25− onTyping,
26− disabled,
27− autoFocus,
28− compact,
29−}: {
30− draftKey: string;
31− placeholder: string;
32− people: Mentionable[];
33− onSend: (body: string) => void;
34− onTyping?: () => void;
35− disabled?: boolean;
36− autoFocus?: boolean;
37− compact?: boolean;
38−}) {
39− const box = useRef<HTMLTextAreaElement>(null);
40− const [text, setText] = useState("");
41− const [match, setMatch] = useState<MentionQuery | null>(null);
42− const [active, setActive] = useState(0);
43− const list = useId();
44− const emoji = useEmojiAutocomplete({ box, setText });
45−
46− // The draft for this conversation, if one was left.
47− useEffect(() => {
48− try {
49− setText(sessionStorage.getItem(`chat-draft:${draftKey}`) ?? "");
50− } catch {
51− setText("");
52− }
53− }, [draftKey]);
54− useEffect(() => {
55− try {
56− if (text) sessionStorage.setItem(`chat-draft:${draftKey}`, text);
57− else sessionStorage.removeItem(`chat-draft:${draftKey}`);
58− } catch {
59− // No storage: the draft lives only while the page does.
60− }
61− }, [draftKey, text]);
62−
63− // Grows with what is written, up to a point.
64− useLayoutEffect(() => {
65− const element = box.current;
66− if (!element) return;
67− element.style.height = "auto";
68− element.style.height = `${Math.min(MAX_HEIGHT, element.scrollHeight)}px`;
69− }, [text]);
14+export function Composer(props: ComposerProps) {
15+ return (
16+ <Suspense fallback={<Loading placeholder={props.placeholder} compact={props.compact} />}>
17+ <ChatEditor {...props} />
18+ </Suspense>
19+ );
20+}
7021
71− const look = () => {
72− const element = box.current;
73− if (!element) return;
74− emoji.look();
75− setMatch(element.selectionStart === element.selectionEnd ? mentionQuery(element.value, element.selectionStart, people) : null);
76− setActive(0);
77− };
78−
79− const complete = (person: Mentionable) => {
80− const element = box.current;
81− if (!element || !match) return;
82− const caret = element.selectionStart;
83− const next = `${element.value.slice(0, match.start)}@${person.name} ${element.value.slice(caret)}`;
84− setText(next);
85− setMatch(null);
86− requestAnimationFrame(() => {
87− const at = match.start + person.name.length + 2;
88− element.focus();
89− element.setSelectionRange(at, at);
90− });
91− };
92−
93− const send = () => {
94− const body = text.trim();
95− if (!body || disabled) return;
96− onSend(body);
97− setText("");
98− setMatch(null);
99− };
100−
101− const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
102− if (emoji.onKeyDown(event)) return;
103− if (match) {
104− const count = match.options.length;
105− if (event.key === "ArrowDown" || event.key === "ArrowUp") {
106− event.preventDefault();
107− setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
108− return;
109− }
110− if ((event.key === "Tab" || event.key === "Enter") && !event.shiftKey) {
111− event.preventDefault();
112− complete(match.options[active] ?? match.options[0]!);
113− return;
114− }
115− if (event.key === "Escape") {
116− event.preventDefault();
117− setMatch(null);
118− return;
119− }
120− }
121− // Enter sends; Shift+Enter, and Enter while composing an IME word, does not.
122− if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
123− event.preventDefault();
124− send();
125− }
126− };
127−
128− const open = match != null;
22+/** The composer's frame, still, while the editor loads. */
23+function Loading({ placeholder, compact }: { placeholder: string; compact?: boolean }) {
12924 return (
130− <div className="relative">
131− {emoji.menu}
132− {match && (
133− <ul
134− id={list}
135− role="listbox"
136− aria-label="Mention someone"
137− 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"
138− >
139− {match.options.map((person, index) => (
140− <li
141− key={`${person.kind}:${person.name}`}
142− id={`${list}-${index}`}
143− role="option"
144− aria-selected={index === active}
145− onMouseDown={(event) => event.preventDefault()}
146− onMouseEnter={() => setActive(index)}
147− onClick={() => complete(person)}
148− className={`flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 ${index === active ? "bg-line" : ""}`}
149− >
150− <MemberAvatar member={person} size={22} />
151− <span className="min-w-0 grow truncate text-sm">
152− <span className="font-medium text-fg">{person.display_name}</span>
153− <span className="ml-1.5 text-faint">@{shownHandle(person)}</span>
154− </span>
155− {person.kind === "agent" && <AgentPill />}
156− </li>
157− ))}
158− </ul>
159− )}
160− <div
161− 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" : ""}`}
162− >
163− <textarea
164− ref={box}
165− value={text}
166− rows={1}
167− disabled={disabled}
168− autoFocus={autoFocus}
169− placeholder={placeholder}
170− aria-label={placeholder}
171− role={open ? "combobox" : undefined}
172− aria-autocomplete="list"
173− aria-expanded={open}
174− aria-controls={open ? list : undefined}
175− aria-activedescendant={open ? `${list}-${active}` : undefined}
176− autoComplete="off"
177− data-1p-ignore
178− data-lpignore="true"
179− onChange={(event) => {
180− setText(event.target.value);
181− onTyping?.();
182− }}
183− onInput={look}
184− onClick={look}
185− onKeyDown={onKeyDown}
186− onBlur={() =>
187− setTimeout(() => {
188− setMatch(null);
189− emoji.dismiss();
190− }, 150)
191− }
192− className={`block w-full resize-none bg-transparent px-3.5 text-[0.9375rem] leading-6 text-fg outline-none placeholder:text-faint ${compact ? "pt-2.5 pb-1" : "pt-3 pb-1.5"}`}
193− />
194− <div className="flex items-center gap-1 px-2 pb-2">
195− <Hint label="Mention someone">
196− <button
197− type="button"
198− aria-label="Mention someone"
199− disabled={disabled}
200− onClick={() => {
201− const element = box.current;
202− if (!element) return;
203− const at = element.selectionStart;
204− const before = element.value.slice(0, at);
205− const insert = before && !/\s$/.test(before) ? " @" : "@";
206− const next = `${before}${insert}${element.value.slice(at)}`;
207− setText(next);
208− requestAnimationFrame(() => {
209− element.focus();
210− element.setSelectionRange(at + insert.length, at + insert.length);
211− setMatch(mentionQuery(next, at + insert.length, people));
212− });
213− }}
214− className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
215− >
216− <AtSign size={15} />
217− </button>
218− </Hint>
219− <EmojiPickerPopover
220− side="top"
221− align="start"
222− onPick={(picked) => {
223− const element = box.current;
224− const at = element?.selectionStart ?? text.length;
225− const end = element?.selectionEnd ?? at;
226− const next = `${text.slice(0, at)}${picked}${text.slice(end)}`;
227− setText(next);
228− requestAnimationFrame(() => {
229− element?.focus();
230− element?.setSelectionRange(at + picked.length, at + picked.length);
231− });
232− }}
233− >
234− <Hint label="Add an emoji">
235− <button
236− type="button"
237− aria-label="Add an emoji"
238− disabled={disabled}
239− className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
240− >
241− <Smile size={15} />
242− </button>
243− </Hint>
244− </EmojiPickerPopover>
245− <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
246− <kbd className="font-sans">Enter</kbd> to send · <kbd className="font-sans">Shift+Enter</kbd> for a new line
247− </span>
248− <button
249− type="button"
250− aria-label="Send"
251− disabled={disabled || !text.trim()}
252− onClick={send}
253− className="ml-auto flex size-8 items-center justify-center rounded-lg bg-accent text-bg transition-[background-color,opacity] hover:bg-accent-hover disabled:bg-line disabled:text-faint"
254− >
255− <ArrowUp size={16} strokeWidth={2.4} />
256− </button>
257− </div>
25+ <div aria-busy="true" className="rounded-xl border border-line-strong bg-surface">
26+ <p className={`px-3.5 text-[0.9375rem] leading-6 text-faint ${compact ? "pt-2.5 pb-1" : "pt-3 pb-1.5"}`}>{placeholder}</p>
27+ <div className="flex items-center gap-1 px-2 pb-2">
28+ <span className="h-7" />
29+ <span aria-hidden="true" className="ml-auto flex size-8 items-center justify-center rounded-lg bg-line text-faint">
30+ <ArrowUp size={16} strokeWidth={2.4} />
31+ </span>
25832 </div>
25933 </div>
26034 );
+840−0
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";
26+import { Hint } from "../ui/hint";
27+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
28+// `:shortcode` completion and the emoji picker (components/emoji).
29+import { useEmojiAutocomplete } from "../emoji/autocomplete";
30+import { EmojiPickerPopover } from "../emoji/picker";
31+import { type MentionQuery, type Mentionable, mentionQuery, safeHref, shownHandle } from "../../lib/chat";
32+import { docToMarkdown, markdownToDoc } from "../../lib/chat-compose";
33+
34+/** How tall the box grows before it scrolls. */
35+const MAX_HEIGHT = 240;
36+
37+export type ComposerProps = {
38+ /** Where the draft is kept while the tab is open; none when editing a message. */
39+ draftKey?: string;
40+ placeholder: string;
41+ people: Mentionable[];
42+ onSend: (body: string) => void;
43+ onTyping?: () => void;
44+ disabled?: boolean;
45+ autoFocus?: boolean;
46+ compact?: boolean;
47+ /** Editing a message: its Markdown to start from, and what leaving does. */
48+ initial?: string;
49+ onCancel?: () => void;
50+};
51+
52+// ---------------------------------------------------------------------------
53+// The editor's rules: Markdown typed as you go, and the shortcuts.
54+
55+/** `*bold*` (one star, as chat writes it) and `**bold**`; `_italic_`; `~strike~` and `~~strike~~`. */
56+const ChatMarks = Extension.create({
57+ name: "chatMarks",
58+ // Before the marks' own rules, so `*x*` is bold here, not italic.
59+ priority: 1000,
60+ addInputRules() {
61+ const { bold, italic, strike } = this.editor.schema.marks;
62+ return [
63+ markInputRule({ find: /(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))$/, type: bold! }),
64+ markInputRule({ find: /(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))$/, type: bold! }),
65+ markInputRule({ find: /(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))$/, type: italic! }),
66+ markInputRule({ find: /(?:^|\s)(~(?!\s+~)((?:[^~]+))~(?!\s+~))$/, type: strike! }),
67+ ];
68+ },
69+});
70+
71+/** The shortcuts beyond the editor's own (Mod+B, Mod+I, Mod+E, Mod+Shift+7 and 8). */
72+function chatKeys(openLink: RefObject<() => void>) {
73+ return Extension.create({
74+ name: "chatKeys",
75+ addKeyboardShortcuts() {
76+ return {
77+ "Mod-Shift-x": () => this.editor.commands.toggleStrike(),
78+ "Mod-Shift-c": () => this.editor.commands.toggleCode(),
79+ "Mod-Alt-Shift-c": () => this.editor.commands.toggleCodeBlock(),
80+ "Mod-Shift-9": () => this.editor.commands.toggleBlockquote(),
81+ "Mod-k": () => {
82+ openLink.current();
83+ return true;
84+ },
85+ };
86+ },
87+ });
88+}
89+
90+/** Whether the caret is inside a node of this type. */
91+function inside(editor: Editor, type: string): boolean {
92+ const { $from } = editor.state.selection;
93+ for (let depth = $from.depth; depth > 0; depth--) if ($from.node(depth).type.name === type) return true;
94+ return false;
95+}
96+
97+/**
98+ * The block the caret is in, as a text box: what the `@` and `:` menus
99+ * read and write, as they would a textarea. Each character of the block's
100+ * text is one position in the document.
101+ */
102+type TextBox = { value: string; selectionStart: number; selectionEnd: number; focus: () => void; setSelectionRange: (from: number, to: number) => void };
103+
104+function textBox(editor: Editor): TextBox {
105+ return {
106+ get value() {
107+ const parent = editor.state.selection.$from.parent;
108+ return parent.textBetween(0, parent.content.size, undefined, "");
109+ },
110+ get selectionStart() {
111+ return editor.state.selection.$from.parentOffset;
112+ },
113+ get selectionEnd() {
114+ const { $from, $to } = editor.state.selection;
115+ return $from.sameParent($to) ? $to.parentOffset : $from.parentOffset;
116+ },
117+ focus: () => editor.commands.focus(),
118+ setSelectionRange: (from, to) => {
119+ const start = editor.state.selection.$from.start();
120+ editor.commands.setTextSelection({ from: start + from, to: start + to });
121+ },
122+ };
123+}
124+
125+/** The caret's block made to read `next`: only what changed is replaced, so its formatting stays. */
126+function replaceBlockText(editor: Editor, next: string) {
127+ const box = textBox(editor);
128+ const now = box.value;
129+ let head = 0;
130+ while (head < now.length && head < next.length && now[head] === next[head]) head++;
131+ let tail = 0;
132+ while (tail < now.length - head && tail < next.length - head && now[now.length - 1 - tail] === next[next.length - 1 - tail]) tail++;
133+ const start = editor.state.selection.$from.start();
134+ const inserted = next.slice(head, next.length - tail);
135+ const tr = editor.state.tr;
136+ if (inserted) tr.insertText(inserted, start + head, start + now.length - tail);
137+ else tr.delete(start + head, start + now.length - tail);
138+ editor.view.dispatch(tr);
139+}
140+
141+/** What a link field holds: an address as typed, made into one a link may go to. */
142+function linkAddress(typed: string): string | null {
143+ const trimmed = typed.trim();
144+ if (!trimmed) return null;
145+ if (/^[\w.+-]+@[\w-]+\.[\w.-]+$/.test(trimmed)) return safeHref(`mailto:${trimmed}`);
146+ if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed) && !trimmed.startsWith("/")) return safeHref(`https://${trimmed}`);
147+ return safeHref(trimmed);
148+}
149+
150+const MOD = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? "⌘" : "Ctrl";
151+
152+/** Every formatting shortcut, for the hints and the list. */
153+const SHORTCUTS: { label: string; keys: string[] }[] = [
154+ { label: "Bold", keys: [MOD, "B"] },
155+ { label: "Italic", keys: [MOD, "I"] },
156+ { label: "Strikethrough", keys: [MOD, "Shift", "X"] },
157+ { label: "Link", keys: [MOD, "K"] },
158+ { label: "Code", keys: [MOD, "Shift", "C"] },
159+ { label: "Code block", keys: [MOD, "Alt", "Shift", "C"] },
160+ { label: "Bulleted list", keys: [MOD, "Shift", "8"] },
161+ { label: "Numbered list", keys: [MOD, "Shift", "7"] },
162+ { label: "Quote", keys: [MOD, "Shift", "9"] },
163+];
164+
165+const keysOf = (label: string) => SHORTCUTS.find((s) => s.label === label)!.keys.join("+");
166+
167+/** Whether the formatting bar is shown: as last chosen on this device, else on wider screens. */
168+function useToolbarShown(): [boolean, (shown: boolean) => void] {
169+ const [shown, setShown] = useState(true);
170+ useEffect(() => {
171+ try {
172+ const saved = localStorage.getItem("chat-formatting");
173+ if (saved) return setShown(saved === "shown");
174+ } catch {
175+ // No storage: the screen decides.
176+ }
177+ setShown(window.matchMedia("(min-width: 768px)").matches);
178+ }, []);
179+ return [
180+ shown,
181+ (next: boolean) => {
182+ setShown(next);
183+ try {
184+ localStorage.setItem("chat-formatting", next ? "shown" : "hidden");
185+ } catch {
186+ // Kept only while the page is open.
187+ }
188+ },
189+ ];
190+}
191+
192+// ---------------------------------------------------------------------------
193+// The composer.
194+
195+/**
196+ * Where a message is written, formatted as it is typed. Enter sends and
197+ * Shift+Enter starts a new line; in a list Enter adds an item (twice to
198+ * leave it), in a code block a line (three times to leave it), and
199+ * Ctrl+Enter sends from anywhere. Markdown typed inline becomes its
200+ * formatting; the bar above the text does the same with a click. `@`
201+ * offers the workspace's people and agents and `:` its emoji (Up and Down
202+ * to move, Tab or Enter to take one, Escape to dismiss). What is sent is
203+ * Markdown. A draft is kept per conversation while the tab is open.
204+ */
205+export default function ChatEditor({ draftKey, placeholder, people, onSend, onTyping, disabled, autoFocus, compact, initial, onCancel }: ComposerProps) {
206+ const editing = initial != null;
207+ const [match, setMatch] = useState<MentionQuery | null>(null);
208+ const [active, setActive] = useState(0);
209+ const [link, setLink] = useState<{ text: string; href: string; existing: boolean } | null>(null);
210+ const [toolbar, setToolbar] = useToolbarShown();
211+ const list = useId();
212+
213+ // What the editor's handlers read: the latest of each, not the first render's.
214+ const keys = useRef<(event: KeyboardEvent) => boolean>(() => false);
215+ const openLink = useRef<() => void>(() => {});
216+ const placeholderRef = useRef(placeholder);
217+ placeholderRef.current = placeholder;
218+ const typing = useRef(onTyping);
219+ typing.current = onTyping;
220+ const loading = useRef(false);
221+
222+ const extensions = useMemo(
223+ () => [
224+ StarterKit.configure({
225+ heading: false,
226+ horizontalRule: false,
227+ underline: false,
228+ dropcursor: false,
229+ gapcursor: false,
230+ trailingNode: false,
231+ link: {
232+ openOnClick: false,
233+ autolink: false,
234+ linkOnPaste: true,
235+ protocols: ["mailto"],
236+ isAllowedUri: (url) => safeHref(url) != null,
237+ HTMLAttributes: { rel: "noopener noreferrer nofollow ugc", target: null },
238+ },
239+ codeBlock: { HTMLAttributes: { spellcheck: "false" } },
240+ }),
241+ ChatMarks,
242+ chatKeys(openLink),
243+ Placeholder.configure({ placeholder: () => placeholderRef.current }),
244+ ],
245+ [],
246+ );
247+
248+ const draftOf = (key: string | undefined): string => {
249+ if (editing) return initial ?? "";
250+ if (!key) return "";
251+ try {
252+ return sessionStorage.getItem(`chat-draft:${key}`) ?? "";
253+ } catch {
254+ return "";
255+ }
256+ };
257+
258+ const editor = useEditor({
259+ extensions,
260+ immediatelyRender: false,
261+ autofocus: autoFocus ? "end" : false,
262+ editable: !disabled,
263+ content: markdownToDoc(draftOf(draftKey)),
264+ editorProps: {
265+ attributes: {
266+ role: "textbox",
267+ "aria-multiline": "true",
268+ "aria-label": placeholder,
269+ "data-1p-ignore": "",
270+ "data-lpignore": "true",
271+ class: "chat-editor outline-none",
272+ },
273+ handleKeyDown: (_view, event) => keys.current(event),
274+ // Text pasted without formatting is read as Markdown, the way it would be sent.
275+ clipboardTextParser: (text, $context, plain, view) => {
276+ if (plain || $context.parent.type.spec.code) return null as unknown as Slice;
277+ const doc = view.state.schema.nodeFromJSON(markdownToDoc(text));
278+ return Slice.maxOpen(doc.content);
279+ },
280+ },
281+ onUpdate: ({ editor: now }) => {
282+ if (loading.current) return;
283+ look(now);
284+ if (!editing && draftKey) {
285+ const markdown = docToMarkdown(now.getJSON());
286+ try {
287+ if (markdown) sessionStorage.setItem(`chat-draft:${draftKey}`, markdown);
288+ else sessionStorage.removeItem(`chat-draft:${draftKey}`);
289+ } catch {
290+ // No storage: the draft lives only while the page does.
291+ }
292+ }
293+ typing.current?.();
294+ },
295+ onSelectionUpdate: ({ editor: now }) => look(now),
296+ onBlur: () =>
297+ setTimeout(() => {
298+ setMatch(null);
299+ emoji.dismiss();
300+ }, 150),
301+ });
302+
303+ // The emoji menu works on the caret's block as on a text box.
304+ const box = useRef<TextBox | null>(null);
305+ box.current = editor ? textBox(editor) : null;
306+ const emoji = useEmojiAutocomplete({
307+ box: box as unknown as RefObject<HTMLTextAreaElement | null>,
308+ setText: (next) => editor && replaceBlockText(editor, next),
309+ });
310+
311+ // Another conversation's draft when the conversation changes.
312+ const shownKey = useRef(draftKey);
313+ useEffect(() => {
314+ if (!editor || shownKey.current === draftKey) return;
315+ shownKey.current = draftKey;
316+ loading.current = true;
317+ editor.commands.setContent(markdownToDoc(draftOf(draftKey)), { emitUpdate: false });
318+ loading.current = false;
319+ setMatch(null);
320+ setLink(null);
321+ // eslint-disable-next-line react-hooks/exhaustive-deps
322+ }, [editor, draftKey]);
323+
324+ useEffect(() => {
325+ editor?.setEditable(!disabled);
326+ }, [editor, disabled]);
327+
328+ const state = useEditorState({
329+ editor,
330+ selector: ({ editor: now }) =>
331+ now
332+ ? {
333+ bold: now.isActive("bold"),
334+ italic: now.isActive("italic"),
335+ strike: now.isActive("strike"),
336+ code: now.isActive("code"),
337+ link: now.isActive("link"),
338+ codeBlock: now.isActive("codeBlock"),
339+ bulletList: now.isActive("bulletList"),
340+ orderedList: now.isActive("orderedList"),
341+ blockquote: now.isActive("blockquote"),
342+ empty: now.isEmpty || docToMarkdown(now.getJSON()).trim() === "",
343+ }
344+ : null,
345+ });
346+
347+ function look(now: Editor) {
348+ emoji.look();
349+ const { selection } = now.state;
350+ if (!selection.empty || inside(now, "codeBlock") || now.isActive("code")) {
351+ setMatch(null);
352+ return;
353+ }
354+ const text = textBox(now);
355+ setMatch(mentionQuery(text.value, text.selectionStart, people));
356+ setActive(0);
357+ }
358+
359+ const complete = (person: Mentionable) => {
360+ if (!editor || !match) return;
361+ const start = editor.state.selection.$from.start();
362+ const caret = editor.state.selection.$from.parentOffset;
363+ editor
364+ .chain()
365+ .focus()
366+ .insertContentAt({ from: start + match.start, to: start + caret }, { type: "text", text: `@${person.name} ` })
367+ .run();
368+ setMatch(null);
369+ };
370+
371+ const send = () => {
372+ if (!editor || disabled) return;
373+ const body = docToMarkdown(editor.getJSON()).trim();
374+ if (!body) return;
375+ onSend(body);
376+ if (editing) return;
377+ editor.commands.clearContent(true);
378+ setMatch(null);
379+ setLink(null);
380+ };
381+
382+ openLink.current = () => {
383+ if (!editor) return;
384+ // On a link already: its whole text, to change or remove.
385+ if (editor.isActive("link")) editor.chain().extendMarkRange("link").run();
386+ const { from, to } = editor.state.selection;
387+ setLink({ text: editor.state.doc.textBetween(from, to, " "), href: editor.getAttributes("link").href ?? "", existing: editor.isActive("link") });
388+ };
389+
390+ keys.current = (event) => {
391+ if (!editor) return false;
392+ if (emoji.onKeyDown(event as unknown as ReactKeyboardEvent<HTMLElement>)) return true;
393+ if (match) {
394+ const count = match.options.length;
395+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
396+ event.preventDefault();
397+ setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
398+ return true;
399+ }
400+ if ((event.key === "Tab" || event.key === "Enter") && !event.shiftKey) {
401+ event.preventDefault();
402+ complete(match.options[active] ?? match.options[0]!);
403+ return true;
404+ }
405+ if (event.key === "Escape") {
406+ event.preventDefault();
407+ setMatch(null);
408+ return true;
409+ }
410+ }
411+ if (event.key === "Escape" && editing) {
412+ event.preventDefault();
413+ onCancel?.();
414+ return true;
415+ }
416+ if (event.key !== "Enter" || event.isComposing) return false;
417+ // Ctrl+Enter (⌘+Enter) sends from anywhere, a list or code too.
418+ if (event.ctrlKey || event.metaKey) {
419+ event.preventDefault();
420+ send();
421+ return true;
422+ }
423+ const inCode = inside(editor, "codeBlock");
424+ const inList = inside(editor, "listItem");
425+ if (inCode) {
426+ // A new line in the block; three Enters in a row leave it.
427+ if (event.shiftKey) {
428+ editor
429+ .chain()
430+ .command(({ tr }) => {
431+ tr.insertText("\n");
432+ return true;
433+ })
434+ .run();
435+ return true;
436+ }
437+ return false;
438+ }
439+ if (inList) {
440+ // A new item, or out of the list from an empty one.
441+ return event.shiftKey ? editor.commands.splitListItem("listItem") || false : false;
442+ }
443+ if (event.shiftKey) {
444+ // A new line: the next paragraph, so lists and quotes can start on it.
445+ return editor.commands.splitBlock();
446+ }
447+ // ``` and a language, then Enter: a code block.
448+ const fence = /^```([\w+#.-]*)$/.exec(textBox(editor).value);
449+ if (fence) {
450+ const start = editor.state.selection.$from.start();
451+ editor
452+ .chain()
453+ .deleteRange({ from: start, to: start + fence[0].length })
454+ .setCodeBlock(fence[1] ? { language: fence[1] } : undefined)
455+ .run();
456+ return true;
457+ }
458+ event.preventDefault();
459+ send();
460+ return true;
461+ };
462+
463+ // The `@` list is the text box's: say so to a screen reader.
464+ useEffect(() => {
465+ const dom = editor?.view.dom;
466+ if (!dom) return;
467+ if (match) {
468+ dom.setAttribute("aria-expanded", "true");
469+ dom.setAttribute("aria-controls", list);
470+ dom.setAttribute("aria-activedescendant", `${list}-${active}`);
471+ } else {
472+ dom.removeAttribute("aria-expanded");
473+ dom.removeAttribute("aria-controls");
474+ dom.removeAttribute("aria-activedescendant");
475+ }
476+ }, [editor, match, active, list]);
477+
478+ const applyLink = (text: string, href: string) => {
479+ if (!editor) return false;
480+ const address = linkAddress(href);
481+ if (!address) return false;
482+ const { from, to } = editor.state.selection;
483+ if (from === to) {
484+ const words = text.trim() || address;
485+ editor
486+ .chain()
487+ .focus()
488+ .insertContent({ type: "text", text: words, marks: [{ type: "link", attrs: { href: address } }] })
489+ .unsetMark("link")
490+ .insertContent({ type: "text", text: " " })
491+ .run();
492+ } else {
493+ editor.chain().focus().extendMarkRange("link").setLink({ href: address }).run();
494+ }
495+ return true;
496+ };
497+
498+ const tool = (label: string, icon: ReactNode, on: boolean | undefined, run: () => void) => (
499+ <Hint label={`${label} (${keysOf(label)})`}>
500+ <button
501+ type="button"
502+ aria-label={label}
503+ aria-pressed={!!on}
504+ disabled={disabled || !editor}
505+ onMouseDown={(event) => event.preventDefault()}
506+ onClick={run}
507+ className={`flex size-8 shrink-0 items-center justify-center rounded-md transition-colors sm:size-7 ${
508+ on ? "bg-accent/15 text-accent" : "text-faint hover:bg-raised hover:text-fg"
509+ }`}
510+ >
511+ {icon}
512+ </button>
513+ </Hint>
514+ );
515+
516+ const chain = () => editor!.chain().focus();
517+
518+ return (
519+ <div className="relative">
520+ {emoji.menu}
521+ {match && (
522+ <ul
523+ id={list}
524+ role="listbox"
525+ aria-label="Mention someone"
526+ 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"
527+ >
528+ {match.options.map((person, index) => (
529+ <li
530+ key={`${person.kind}:${person.name}`}
531+ id={`${list}-${index}`}
532+ role="option"
533+ aria-selected={index === active}
534+ onMouseDown={(event) => event.preventDefault()}
535+ onMouseEnter={() => setActive(index)}
536+ onClick={() => complete(person)}
537+ className={`flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 ${index === active ? "bg-line" : ""}`}
538+ >
539+ <MemberAvatar member={person} size={22} />
540+ <span className="min-w-0 grow truncate text-sm">
541+ <span className="font-medium text-fg">{person.display_name}</span>
542+ <span className="ml-1.5 text-faint">@{shownHandle(person)}</span>
543+ </span>
544+ {person.kind === "agent" && <AgentPill />}
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">
625+ <button
626+ 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+ }}
641+ className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
642+ >
643+ <AtSign size={15} />
644+ </button>
645+ </Hint>
646+ <EmojiPickerPopover side="top" align="start" onPick={(picked) => editor?.chain().focus().insertContent({ type: "text", text: picked }).run()}>
647+ <Hint label="Add an emoji">
648+ <button
649+ type="button"
650+ aria-label="Add an emoji"
651+ disabled={disabled || !editor}
652+ className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
653+ >
654+ <Smile size={15} />
655+ </button>
656+ </Hint>
657+ </EmojiPickerPopover>
658+ {editing ? (
659+ <>
660+ <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
661+ <kbd className="font-sans">Esc</kbd> to cancel · <kbd className="font-sans">Enter</kbd> to save
662+ </span>
663+ <span className="ml-auto flex items-center gap-1.5">
664+ <button
665+ type="button"
666+ onClick={onCancel}
667+ className="h-8 rounded-lg border border-line px-3 text-[0.8125rem] font-medium text-fg-soft transition-colors hover:border-line-strong hover:bg-raised"
668+ >
669+ Cancel
670+ </button>
671+ <button
672+ type="button"
673+ disabled={disabled || !editor || state?.empty}
674+ onClick={send}
675+ className="h-8 rounded-lg bg-accent px-3 text-[0.8125rem] font-medium text-bg transition-colors hover:bg-accent-hover disabled:bg-line disabled:text-faint"
676+ >
677+ Save
678+ </button>
679+ </span>
680+ </>
681+ ) : (
682+ <>
683+ <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
684+ <kbd className="font-sans">Enter</kbd> to send · <kbd className="font-sans">Shift+Enter</kbd> for a new line
685+ </span>
686+ <button
687+ type="button"
688+ aria-label="Send"
689+ disabled={disabled || !editor || state?.empty !== false}
690+ onMouseDown={(event) => event.preventDefault()}
691+ onClick={send}
692+ className="ml-auto flex size-8 items-center justify-center rounded-lg bg-accent text-bg transition-[background-color,opacity] hover:bg-accent-hover disabled:bg-line disabled:text-faint"
693+ >
694+ <ArrowUp size={16} strokeWidth={2.4} />
695+ </button>
696+ </>
697+ )}
698+ </div>
699+ </div>
700+ </div>
701+ );
702+}
703+
704+/** The bar's last button: every shortcut, and what Markdown typed becomes. */
705+function ShortcutList() {
706+ return (
707+ <Popover>
708+ <Hint label="Formatting shortcuts">
709+ <PopoverTrigger
710+ aria-label="Formatting shortcuts"
711+ onMouseDown={(event) => event.preventDefault()}
712+ className="ml-auto flex size-8 shrink-0 items-center justify-center rounded-md text-faint transition-colors outline-none hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/50 sm:size-7"
713+ >
714+ <Keyboard size={15} />
715+ </PopoverTrigger>
716+ </Hint>
717+ <PopoverContent side="top" align="end" className="w-80 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
718+ <p className="mb-2 text-xs font-semibold text-fg">Formatting shortcuts</p>
719+ <dl className="space-y-1 text-[0.8125rem]">
720+ {SHORTCUTS.map((shortcut) => (
721+ <div key={shortcut.label} className="flex items-center justify-between gap-3">
722+ <dt className="text-fg-soft">{shortcut.label}</dt>
723+ <dd className="flex gap-1">
724+ {shortcut.keys.map((key) => (
725+ <kbd key={key} className="rounded border border-line-strong bg-surface px-1.5 font-sans text-[0.6875rem] text-muted">
726+ {key}
727+ </kbd>
728+ ))}
729+ </dd>
730+ </div>
731+ ))}
732+ </dl>
733+ <p className="mt-3 mb-1.5 text-xs font-semibold text-fg">Or type Markdown</p>
734+ <p className="font-mono text-[0.75rem] leading-relaxed text-muted">
735+ *bold* _italic_ ~strike~ `code`
736+ <br />
737+ - list · 1. list · &gt; quote
738+ <br />
739+ ``` code block
740+ </p>
741+ <p className="mt-3 text-[0.75rem] text-faint">
742+ <kbd className="font-sans">{MOD}+Enter</kbd> sends from a list or code block.
743+ </p>
744+ </PopoverContent>
745+ </Popover>
746+ );
747+}
748+
749+/** Adding or changing a link: its text when nothing is selected, and where it goes. */
750+function LinkForm({
751+ initial,
752+ onApply,
753+ onRemove,
754+ onClose,
755+}: {
756+ initial: { text: string; href: string; existing: boolean };
757+ onApply: (text: string, href: string) => boolean;
758+ onRemove?: () => void;
759+ onClose: () => void;
760+}) {
761+ const [text, setText] = useState(initial.text);
762+ const [href, setHref] = useState(initial.href);
763+ const [wrong, setWrong] = useState(false);
764+ const askText = !initial.text;
765+ const id = useId();
766+ const keys = (event: ReactKeyboardEvent) => {
767+ if (event.key === "Escape") {
768+ event.preventDefault();
769+ event.stopPropagation();
770+ onClose();
771+ }
772+ };
773+ return (
774+ <form
775+ aria-label={initial.existing ? "Edit link" : "Add a link"}
776+ onSubmit={(event) => {
777+ event.preventDefault();
778+ setWrong(!onApply(text, href));
779+ }}
780+ onKeyDown={keys}
781+ 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"
782+ >
783+ {askText && (
784+ <div className="space-y-1">
785+ <label htmlFor={`${id}-text`} className="text-xs font-medium text-muted">
786+ Text
787+ </label>
788+ <input
789+ id={`${id}-text`}
790+ autoFocus
791+ value={text}
792+ onChange={(event) => setText(event.target.value)}
793+ placeholder="What the link says"
794+ autoComplete="off"
795+ 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"
796+ />
797+ </div>
798+ )}
799+ <div className="space-y-1">
800+ <label htmlFor={`${id}-href`} className="text-xs font-medium text-muted">
801+ Link
802+ </label>
803+ <input
804+ id={`${id}-href`}
805+ autoFocus={!askText}
806+ value={href}
807+ onChange={(event) => {
808+ setHref(event.target.value);
809+ setWrong(false);
810+ }}
811+ placeholder="https://"
812+ inputMode="url"
813+ autoComplete="off"
814+ aria-invalid={wrong}
815+ aria-describedby={wrong ? `${id}-wrong` : undefined}
816+ 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"
817+ />
818+ {wrong && (
819+ <p id={`${id}-wrong`} className="text-xs text-danger">
820+ A link goes to a web address (https://…), an email address or a page on g1t.
821+ </p>
822+ )}
823+ </div>
824+ <div className="flex items-center gap-1.5 pt-0.5">
825+ {onRemove && (
826+ <button type="button" onClick={onRemove} className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[0.8125rem] text-muted hover:bg-line hover:text-fg">
827+ <Unlink size={14} />
828+ Remove link
829+ </button>
830+ )}
831+ <button type="button" onClick={onClose} className="ml-auto h-8 rounded-md px-3 text-[0.8125rem] text-muted hover:bg-line hover:text-fg">
832+ Cancel
833+ </button>
834+ <button type="submit" disabled={!href.trim()} className="h-8 rounded-md bg-accent px-3 text-[0.8125rem] font-medium text-bg hover:bg-accent-hover disabled:bg-line disabled:text-faint">
835+ {initial.existing ? "Save" : "Add link"}
836+ </button>
837+ </div>
838+ </form>
839+ );
840+}
+2−2
198198 }, [language, code]);
199199 return (
200200 <div className="group/code relative my-1">
201− <pre className="overflow-x-auto rounded-md border border-line bg-bg px-3 py-2 font-mono text-[0.8125rem] leading-relaxed text-fg-soft [scrollbar-width:thin]">
201+ <pre className="overflow-x-auto rounded-md border border-line bg-bg px-3 py-2 pointer-coarse:pr-10 font-mono text-[0.8125rem] leading-relaxed text-fg-soft [scrollbar-width:thin]">
202202 <code>
203203 {rows
204204 ? rows.map((row, index) => (
214214 </code>
215215 </pre>
216216 <div className="absolute top-1.5 right-1.5 flex items-center gap-2 opacity-0 transition-opacity group-hover/code:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100">
217− {language && <span className="font-mono text-[0.6875rem] text-faint">{language}</span>}
217+ {language && <span className="font-mono text-[0.6875rem] text-faint pointer-coarse:hidden">{language}</span>}
218218 <Hint label={copied ? "Copied" : "Copy code"}>
219219 <button
220220 type="button"
+83−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { type DocNode, docToMarkdown, escapeText, markdownToDoc } from "./chat-compose.ts";
5+
6+const text = (value: string, ...marks: (string | { type: string; attrs?: Record<string, unknown> })[]): DocNode => ({
7+ type: "text",
8+ text: value,
9+ ...(marks.length ? { marks: marks.map((mark) => (typeof mark === "string" ? { type: mark } : mark)) } : {}),
10+});
11+const p = (...content: DocNode[]): DocNode => (content.length ? { type: "paragraph", content } : { type: "paragraph" });
12+const doc = (...content: DocNode[]): DocNode => ({ type: "doc", content });
13+
14+test("formatting is written as Markdown", () => {
15+ assert.equal(
16+ docToMarkdown(doc(p(text("bold", "bold"), text(" "), text("it", "italic"), text(" "), text("gone", "strike"), text(" "), text("a*b", "code")))),
17+ "**bold** _it_ ~~gone~~ `a*b`",
18+ );
19+ // Marks that overlap open and close once.
20+ assert.equal(docToMarkdown(doc(p(text("a", "bold"), text("b", "bold", "italic"), text("c", "bold")))), "**a_b_c**");
21+ // Spaces at a mark's edges sit outside it.
22+ assert.equal(docToMarkdown(doc(p(text("say "), text(" hi ", "bold"), text("there")))), "say **hi** there");
23+ assert.equal(docToMarkdown(doc(p(text("tick ` in", "code")))), "`` tick ` in ``");
24+});
25+
26+test("links: with their text, or bare when the text is the address", () => {
27+ const link = (href: string) => ({ type: "link", attrs: { href } });
28+ assert.equal(docToMarkdown(doc(p(text("the docs", link("https://g1t.sh/docs"))))), "[the docs](https://g1t.sh/docs)");
29+ assert.equal(docToMarkdown(doc(p(text("https://g1t.sh", link("https://g1t.sh"))))), "https://g1t.sh");
30+ assert.equal(docToMarkdown(doc(p(text("x", link("https://a.example/a b"))))), "[x](<https://a.example/a%20b>)");
31+});
32+
33+test("each paragraph is a line; an empty one is a blank line", () => {
34+ assert.equal(docToMarkdown(doc(p(text("one")), p(text("two")), p(), p(text("three")))), "one\ntwo\n\nthree");
35+ assert.equal(docToMarkdown(doc(p(), p(text("x")), p())), "x");
36+});
37+
38+test("text that only looks like Markdown is escaped, and mentions stay mentions", () => {
39+ assert.equal(escapeText("2*3 and snake_case and _edge_ and [x] and `t` and ~5"), "2\\*3 and snake_case and \\_edge\\_ and \\[x\\] and \\`t\\` and \\~5");
40+ assert.equal(docToMarkdown(doc(p(text("- not a list")), p(text("1. not either")), p(text("# nor this")), p(text("> or this")))), "\\- not a list\n1\\. not either\n\\# nor this\n\\> or this");
41+ assert.equal(docToMarkdown(doc(p(text("ask @ana in #web about #12")))), "ask @ana in #web about #12");
42+});
43+
44+test("lists, quotes and code blocks", () => {
45+ const item = (...content: DocNode[]): DocNode => ({ type: "listItem", content });
46+ assert.equal(
47+ docToMarkdown(
48+ doc(
49+ p(text("Plan:")),
50+ { type: "bulletList", content: [item(p(text("one"))), item(p(text("two")), { type: "orderedList", attrs: { start: 1 }, content: [item(p(text("deep")))] })] },
51+ p(text("after")),
52+ ),
53+ ),
54+ "Plan:\n\n- one\n- two\n 1. deep\n\nafter",
55+ );
56+ assert.equal(docToMarkdown(doc({ type: "orderedList", attrs: { start: 3 }, content: [item(p(text("c"))), item(p(text("d")))] })), "3. c\n4. d");
57+ assert.equal(docToMarkdown(doc({ type: "blockquote", content: [p(text("quoted")), p(), p(text("more"))] })), "> quoted\n>\n> more");
58+ assert.equal(docToMarkdown(doc({ type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "const a = 1;\n\n\nconst b = `x`;" }] })), "```ts\nconst a = 1;\n\n\nconst b = `x`;\n```");
59+ assert.equal(docToMarkdown(doc({ type: "codeBlock", attrs: { language: null }, content: [{ type: "text", text: "```\nfence\n```" }] })), "````\n```\nfence\n```\n````");
60+});
61+
62+test("Markdown comes back into the composer as it was written", () => {
63+ const samples = [
64+ "**bold** _it_ ~~gone~~ `code`",
65+ "one\ntwo\n\nthree",
66+ "Plan:\n\n- one\n- two\n 1. deep\n\nafter",
67+ "3. c\n4. d",
68+ "> quoted\n>\n> more",
69+ "```ts\nconst a = 1;\n\nconst b = 2;\n```",
70+ "[the docs](https://g1t.sh/docs) and https://g1t.sh",
71+ "ask @ana in #web about g1t#12",
72+ "\\- not a list and 2\\*3 and snake_case",
73+ "**a _b_ c**",
74+ ];
75+ for (const sample of samples) assert.equal(docToMarkdown(markdownToDoc(sample)), sample, sample);
76+});
77+
78+test("Markdown the composer cannot write still comes in", () => {
79+ // A heading is a bold line; a rule is left out; an image is a link.
80+ assert.equal(docToMarkdown(markdownToDoc("## Notes\n---\ntext")), "**Notes**\ntext");
81+ assert.equal(docToMarkdown(markdownToDoc("*star em* and __strong__ and ~one~")), "_star em_ and **strong** and ~~one~~");
82+ assert.deepEqual(markdownToDoc(""), { type: "doc", content: [{ type: "paragraph" }] });
83+});
+292−0
1+/**
2+ * The chat composer's document and the Markdown a message is stored as,
3+ * each turned into the other. The composer (components/chat/editor.tsx)
4+ * edits a small rich-text document; what it sends, keeps as a draft and
5+ * edits again is Markdown, the same text the API, agents and notifications
6+ * read. Pure, on the editor's JSON, so it is tested without a browser
7+ * (chat-compose.test.ts).
8+ *
9+ * In the document each paragraph is one line of the message: Shift+Enter
10+ * starts a new one, an empty one is a blank line.
11+ */
12+import { type Block, type Span, blocks } from "@g1t/contracts/chat-markdown";
13+
14+/** The editor's JSON, as much of it as is read here. */
15+export type DocNode = {
16+ type: string;
17+ attrs?: Record<string, unknown>;
18+ content?: DocNode[];
19+ text?: string;
20+ marks?: { type: string; attrs?: Record<string, unknown> }[];
21+};
22+
23+type Mark = { type: string; attrs?: Record<string, unknown> };
24+
25+// ---------------------------------------------------------------------------
26+// Markdown to the document.
27+
28+function textNode(text: string, marks: Mark[]): DocNode | null {
29+ if (!text) return null;
30+ return marks.length ? { type: "text", text, marks } : { type: "text", text };
31+}
32+
33+function spanNodes(list: Span[], marks: Mark[] = []): DocNode[] {
34+ const out: DocNode[] = [];
35+ const add = (node: DocNode | null) => node && out.push(node);
36+ for (const span of list) {
37+ switch (span.t) {
38+ case "text":
39+ add(textNode(span.v, marks));
40+ break;
41+ case "code":
42+ add(textNode(span.v, [...marks, { type: "code" }]));
43+ break;
44+ case "strong":
45+ out.push(...spanNodes(span.c, [...marks, { type: "bold" }]));
46+ break;
47+ case "em":
48+ out.push(...spanNodes(span.c, [...marks, { type: "italic" }]));
49+ break;
50+ case "del":
51+ out.push(...spanNodes(span.c, [...marks, { type: "strike" }]));
52+ break;
53+ case "link":
54+ out.push(...spanNodes(span.c, [{ type: "link", attrs: { href: span.href } }, ...marks]));
55+ break;
56+ case "mention":
57+ add(textNode(`@${span.name}`, marks));
58+ break;
59+ case "channel":
60+ add(textNode(`#${span.name}`, marks));
61+ break;
62+ case "ref":
63+ add(textNode(`${span.repo ?? ""}#${span.number}`, marks));
64+ break;
65+ }
66+ }
67+ return out;
68+}
69+
70+function paragraph(spans: Span[]): DocNode {
71+ const content = spanNodes(spans);
72+ return content.length ? { type: "paragraph", content } : { type: "paragraph" };
73+}
74+
75+function blockNodes(list: Block[]): DocNode[] {
76+ const out: DocNode[] = [];
77+ list.forEach((block, index) => {
78+ // Paragraphs one after another were apart by a blank line: an empty line between them.
79+ if (block.t === "p" && index > 0 && list[index - 1]!.t === "p") out.push({ type: "paragraph" });
80+ switch (block.t) {
81+ case "p":
82+ for (const line of block.lines) out.push(paragraph(line));
83+ break;
84+ case "heading":
85+ // Chat has no headings to write; one comes back as a bold line.
86+ out.push(paragraph([{ t: "strong", c: block.c }]));
87+ break;
88+ case "hr":
89+ break;
90+ case "code":
91+ out.push({ type: "codeBlock", attrs: { language: block.lang }, ...(block.v ? { content: [{ type: "text", text: block.v }] } : {}) });
92+ break;
93+ case "quote": {
94+ const content = blockNodes(block.c);
95+ out.push({ type: "blockquote", content: content.length ? content : [{ type: "paragraph" }] });
96+ break;
97+ }
98+ case "list":
99+ out.push({
100+ type: block.ordered ? "orderedList" : "bulletList",
101+ ...(block.ordered ? { attrs: { start: block.start } } : {}),
102+ content: block.items.map((item) => {
103+ const content = blockNodes(item);
104+ // An item starts with a line of text.
105+ if (content[0]?.type !== "paragraph") content.unshift({ type: "paragraph" });
106+ return { type: "listItem", content };
107+ }),
108+ });
109+ break;
110+ }
111+ });
112+ return out;
113+}
114+
115+/** A message's Markdown as the composer's document. */
116+export function markdownToDoc(markdown: string): DocNode {
117+ const content = blockNodes(blocks(markdown));
118+ return { type: "doc", content: content.length ? content : [{ type: "paragraph" }] };
119+}
120+
121+// ---------------------------------------------------------------------------
122+// The document to Markdown.
123+
124+/** Marks from the outside in: a link holds bold, bold holds code. */
125+const ORDER = ["link", "bold", "italic", "strike", "code"];
126+const OPEN: Record<string, string> = { bold: "**", italic: "_", strike: "~~" };
127+
128+function sameMark(a: Mark, b: Mark): boolean {
129+ return a.type === b.type && (a.type !== "link" || a.attrs?.href === b.attrs?.href);
130+}
131+
132+/** Text as Markdown shows it as typed: its marks escaped. */
133+export function escapeText(text: string): string {
134+ return text
135+ .replace(/\\(?=[\\`*_{}[\]()#+\-.!~>|<=:@])/g, "\\\\")
136+ .replace(/[`*~[\]]/g, (char) => `\\${char}`)
137+ .replace(/_/g, (char, at: number, all: string) => {
138+ // `snake_case` stays as typed: only an `_` at a word's edge could start or end italics.
139+ const before = all[at - 1] ?? "";
140+ const after = all[at + 1] ?? "";
141+ return /\w/.test(before) && /\w/.test(after) ? char : `\\${char}`;
142+ });
143+}
144+
145+/** A line that would read as a block's start (`# `, `- `, `1. `, `> `, `---`) when it is only text. */
146+function escapeLineStart(line: string): string {
147+ return line
148+ .replace(/^(\s*)(#{1,6}(?:\s|$))/, "$1\\$2")
149+ .replace(/^(\s*)([-+](?:\s|$))/, "$1\\$2")
150+ .replace(/^(\s*)(\d{1,9})([.)])(\s|$)/, "$1$2\\$3$4")
151+ .replace(/^(\s*)(>)/, "$1\\$2")
152+ .replace(/^(\s*)([-_=]{3,}\s*)$/, "$1\\$2")
153+ .replace(/^(\s*)•/, "$1\\•");
154+}
155+
156+function codeSpan(text: string): string {
157+ if (!text.includes("`")) return `\`${text}\``;
158+ return `\`\` ${text} \`\``;
159+}
160+
161+function linkTarget(href: string): string {
162+ return /[\s()<>]/.test(href) ? `<${href.replace(/[<>\s]/g, encodeURIComponent)}>` : href;
163+}
164+
165+/** A line of inline content (text with marks) as Markdown. */
166+function inlineMarkdown(nodes: DocNode[]): string {
167+ let out = "";
168+ const open: { mark: Mark; text: string }[] = [];
169+ // A link's text is gathered so a bare URL can be written bare.
170+ const close = (count: number) => {
171+ for (let n = 0; n < count; n++) {
172+ const top = open.pop()!;
173+ // Spaces at a mark's end go after its closing mark: `**a **` would not be bold.
174+ const body = top.text;
175+ const trailing = /\s*$/.exec(body)![0];
176+ const inner = body.slice(0, body.length - trailing.length);
177+ let written: string;
178+ if (top.mark.type === "link") {
179+ const href = String(top.mark.attrs?.href ?? "");
180+ written = inner === escapeText(href) && /^https?:\/\//i.test(href) && !/[\s()<>]/.test(href) ? href : `[${inner}](${linkTarget(href)})`;
181+ } else if (!inner) {
182+ written = "";
183+ } else {
184+ const delimiter = OPEN[top.mark.type] ?? "";
185+ written = `${delimiter}${inner}${delimiter}`;
186+ }
187+ append(written + trailing);
188+ }
189+ };
190+ const append = (text: string) => {
191+ if (open.length) open[open.length - 1]!.text += text;
192+ else out += text;
193+ };
194+ for (const node of nodes) {
195+ if (node.type === "hardBreak") {
196+ close(open.length);
197+ out += "\n";
198+ continue;
199+ }
200+ if (node.type !== "text" || !node.text) continue;
201+ const marks = [...(node.marks ?? [])]
202+ .filter((mark) => ORDER.includes(mark.type))
203+ .sort((a, b) => ORDER.indexOf(a.type) - ORDER.indexOf(b.type));
204+ // Close what this text no longer has, and everything opened after it.
205+ let keep = 0;
206+ while (keep < open.length && keep < marks.length && sameMark(open[keep]!.mark, marks[keep]!)) keep++;
207+ close(open.length - keep);
208+ const code = marks.some((mark) => mark.type === "code");
209+ let text = node.text;
210+ // Spaces before a mark's start go before it: `** a**` would not be bold.
211+ const opening = marks.slice(keep).filter((mark) => mark.type !== "code");
212+ if (opening.length) {
213+ const leading = /^\s*/.exec(text)![0];
214+ append(leading);
215+ text = text.slice(leading.length);
216+ }
217+ for (const mark of marks.slice(keep)) if (mark.type !== "code") open.push({ mark, text: "" });
218+ append(code ? codeSpan(text) : escapeText(text));
219+ }
220+ close(open.length);
221+ return out;
222+}
223+
224+function prefixLines(text: string, first: string, rest: string): string {
225+ return text
226+ .split("\n")
227+ .map((line, index) => (index === 0 ? first : line ? rest : rest.trimEnd()) + line)
228+ .join("\n");
229+}
230+
231+function blockMarkdown(node: DocNode): string | null {
232+ switch (node.type) {
233+ case "paragraph": {
234+ const line = inlineMarkdown(node.content ?? []);
235+ return line
236+ .split("\n")
237+ .map((part) => escapeLineStart(part))
238+ .join("\n");
239+ }
240+ case "codeBlock": {
241+ const text = (node.content ?? []).map((child) => child.text ?? "").join("");
242+ const longest = Math.max(2, ...(text.match(/`{3,}/g) ?? []).map((run) => run.length));
243+ const fence = "`".repeat(longest + 1);
244+ const language = typeof node.attrs?.language === "string" ? node.attrs.language : "";
245+ return `${fence}${language}\n${text}\n${fence}`;
246+ }
247+ case "blockquote": {
248+ const inner = blocksMarkdown(node.content ?? []);
249+ return inner
250+ .split("\n")
251+ .map((line) => (line ? `> ${line}` : ">"))
252+ .join("\n");
253+ }
254+ case "bulletList":
255+ case "orderedList": {
256+ const ordered = node.type === "orderedList";
257+ const start = ordered ? Number(node.attrs?.start ?? 1) || 1 : 1;
258+ return (node.content ?? [])
259+ .map((item, index) => {
260+ const marker = ordered ? `${start + index}. ` : "- ";
261+ const inner = blocksMarkdown(item.content ?? [], true);
262+ return prefixLines(inner, marker, " ".repeat(marker.length));
263+ })
264+ .join("\n");
265+ }
266+ default:
267+ return null;
268+ }
269+}
270+
271+function blocksMarkdown(nodes: DocNode[], tight = false): string {
272+ let out = "";
273+ let previous: string | null = null;
274+ for (const node of nodes) {
275+ const text = blockMarkdown(node);
276+ if (text == null) continue;
277+ if (previous != null) {
278+ // A line follows a line, and in a list item everything does; anything else stands apart by a blank line.
279+ out += tight || (previous === "paragraph" && node.type === "paragraph") ? "\n" : "\n\n";
280+ }
281+ out += text;
282+ previous = node.type;
283+ }
284+ return out;
285+}
286+
287+/** The composer's document as the Markdown a message is sent as. */
288+export function docToMarkdown(doc: DocNode): string {
289+ return blocksMarkdown(doc.content ?? [])
290+ .replace(/^(?:[ \t]*\n)+/, "")
291+ .replace(/(?:\n[ \t]*)+$/, "");
292+}
+5−0
2020 "@g1t/contracts": "*",
2121 "@g1t/theme": "*",
2222 "@heycatch/sdk": "0.8.3",
23+ "@tiptap/core": "^3.31.4",
24+ "@tiptap/extensions": "^3.31.4",
25+ "@tiptap/pm": "^3.31.4",
26+ "@tiptap/react": "^3.31.4",
27+ "@tiptap/starter-kit": "^3.31.4",
2328 "class-variance-authority": "^0.7.1",
2429 "clsx": "^2.1.1",
2530 "cmdk": "^1.1.1",
+250−0
8080 "@g1t/contracts": "*",
8181 "@g1t/theme": "*",
8282 "@heycatch/sdk": "0.8.3",
83+ "@tiptap/core": "^3.31.4",
84+ "@tiptap/extensions": "^3.31.4",
85+ "@tiptap/pm": "^3.31.4",
86+ "@tiptap/react": "^3.31.4",
87+ "@tiptap/starter-kit": "^3.31.4",
8388 "class-variance-authority": "^0.7.1",
8489 "clsx": "^2.1.1",
8590 "cmdk": "^1.1.1",
52325237 "@tiptap/pm": "3.31.4"
52335238 }
52345239 },
5240+ "node_modules/@tiptap/extension-blockquote": {
5241+ "version": "3.31.4",
5242+ "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.4.tgz",
5243+ "integrity": "sha512-FHnbvCIW0eDKgUUFjmvEB0ax9YUBW7/Unm+38qpuQROvU872yRqhnDtycKT9IKk46jNdjM4A4fAGUbge10CrAA==",
5244+ "license": "MIT",
5245+ "funding": {
5246+ "type": "github",
5247+ "url": "https://github.com/sponsors/ueberdosis"
5248+ },
5249+ "peerDependencies": {
5250+ "@tiptap/core": "3.31.4",
5251+ "@tiptap/pm": "3.31.4"
5252+ }
5253+ },
52355254 "node_modules/@tiptap/extension-bold": {
52365255 "version": "3.31.4",
52375256 "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.4.tgz",
52635282 "@tiptap/pm": "3.31.4"
52645283 }
52655284 },
5285+ "node_modules/@tiptap/extension-bullet-list": {
5286+ "version": "3.31.4",
5287+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.4.tgz",
5288+ "integrity": "sha512-gHn1RDR4olCnmKpH+G9x+f8R+iKPNVdLlvbFkIpR/stjabZi7fB4Rr5GiHWsulbtxAjdQrNjnVeNdGM4rBxApw==",
5289+ "license": "MIT",
5290+ "funding": {
5291+ "type": "github",
5292+ "url": "https://github.com/sponsors/ueberdosis"
5293+ },
5294+ "peerDependencies": {
5295+ "@tiptap/extension-list": "3.31.4"
5296+ }
5297+ },
52665298 "node_modules/@tiptap/extension-code": {
52675299 "version": "3.31.4",
52685300 "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.4.tgz",
52765308 "@tiptap/core": "3.31.4"
52775309 }
52785310 },
5311+ "node_modules/@tiptap/extension-code-block": {
5312+ "version": "3.31.4",
5313+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.4.tgz",
5314+ "integrity": "sha512-su/B/cfQ1HpiHl6JfJ6wa+RVEptiYsV4dCntE8XQNj2dUcII4y6w6PQbbswOoDrU79uXHsAvpy44215rghG4LA==",
5315+ "license": "MIT",
5316+ "funding": {
5317+ "type": "github",
5318+ "url": "https://github.com/sponsors/ueberdosis"
5319+ },
5320+ "peerDependencies": {
5321+ "@tiptap/core": "3.31.4",
5322+ "@tiptap/pm": "3.31.4"
5323+ }
5324+ },
5325+ "node_modules/@tiptap/extension-document": {
5326+ "version": "3.31.4",
5327+ "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.4.tgz",
5328+ "integrity": "sha512-vT20u62RWR+BLIyNJ54e24MPUY4zm8VF7tcgJnxQXi7BgVJWcNMtMWWDIkPJ4JNXwfoIznKh3jzYsaYmbCJgQQ==",
5329+ "license": "MIT",
5330+ "funding": {
5331+ "type": "github",
5332+ "url": "https://github.com/sponsors/ueberdosis"
5333+ },
5334+ "peerDependencies": {
5335+ "@tiptap/core": "3.31.4"
5336+ }
5337+ },
5338+ "node_modules/@tiptap/extension-dropcursor": {
5339+ "version": "3.31.4",
5340+ "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.4.tgz",
5341+ "integrity": "sha512-5TbCPCC4RL+QT1NYHjXCYDBHBtVqYOFvFEnk3v98lvXCM2PNL13ofXUfJPuykAVy1OdTAx4DlZOQAD9WKy/IdQ==",
5342+ "license": "MIT",
5343+ "funding": {
5344+ "type": "github",
5345+ "url": "https://github.com/sponsors/ueberdosis"
5346+ },
5347+ "peerDependencies": {
5348+ "@tiptap/extensions": "3.31.4"
5349+ }
5350+ },
52795351 "node_modules/@tiptap/extension-floating-menu": {
52805352 "version": "3.31.4",
52815353 "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.4.tgz",
52925364 "@tiptap/pm": "3.31.4"
52935365 }
52945366 },
5367+ "node_modules/@tiptap/extension-gapcursor": {
5368+ "version": "3.31.4",
5369+ "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.4.tgz",
5370+ "integrity": "sha512-hBogUAepQH6IQUhaePhPWzQ1Oeu+yqlJVMKO06Eue5m36uRy1PZZAQ780pCGT0SC4R5MH2td1N8EUQ7bupQdDA==",
5371+ "license": "MIT",
5372+ "funding": {
5373+ "type": "github",
5374+ "url": "https://github.com/sponsors/ueberdosis"
5375+ },
5376+ "peerDependencies": {
5377+ "@tiptap/extensions": "3.31.4"
5378+ }
5379+ },
5380+ "node_modules/@tiptap/extension-hard-break": {
5381+ "version": "3.31.4",
5382+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.4.tgz",
5383+ "integrity": "sha512-oLNajLyUvRMg1qtzFgKGduygTlpaHFB9PU+M+pUWdA4sa1bRnxS2Y++Ao4lHYopJ7nxatLndrYoCIHkotqkPDQ==",
5384+ "license": "MIT",
5385+ "funding": {
5386+ "type": "github",
5387+ "url": "https://github.com/sponsors/ueberdosis"
5388+ },
5389+ "peerDependencies": {
5390+ "@tiptap/core": "3.31.4"
5391+ }
5392+ },
5393+ "node_modules/@tiptap/extension-heading": {
5394+ "version": "3.31.4",
5395+ "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.4.tgz",
5396+ "integrity": "sha512-KqlSeLathpdxCc3kVHBCA/RkHvb+V7u5vgiSEu1Cv3L70r7F19COMGODav/k1AGYBgEXKL6lM9cy8cloE5/28g==",
5397+ "license": "MIT",
5398+ "funding": {
5399+ "type": "github",
5400+ "url": "https://github.com/sponsors/ueberdosis"
5401+ },
5402+ "peerDependencies": {
5403+ "@tiptap/core": "3.31.4"
5404+ }
5405+ },
5406+ "node_modules/@tiptap/extension-horizontal-rule": {
5407+ "version": "3.31.4",
5408+ "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.4.tgz",
5409+ "integrity": "sha512-ws0Kh8kfHL7ESILSXj0HJAXs8G10ewlW1sedXKm8dm0op9L8il+xl8bEJVoqG/jMf77900Fc2CaQldcCGRpF1g==",
5410+ "license": "MIT",
5411+ "funding": {
5412+ "type": "github",
5413+ "url": "https://github.com/sponsors/ueberdosis"
5414+ },
5415+ "peerDependencies": {
5416+ "@tiptap/core": "3.31.4",
5417+ "@tiptap/pm": "3.31.4"
5418+ }
5419+ },
52955420 "node_modules/@tiptap/extension-italic": {
52965421 "version": "3.31.4",
52975422 "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz",
53055430 "@tiptap/core": "3.31.4"
53065431 }
53075432 },
5433+ "node_modules/@tiptap/extension-link": {
5434+ "version": "3.31.4",
5435+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.4.tgz",
5436+ "integrity": "sha512-LLxW3qfeL9gvDdMPVdxYFYZ0zZSjsoIDhOqjJ5U2UfaK3U2iwkKTBgV2qT+FWf3uUHmBRjBZ08q3dRPmWBAD5g==",
5437+ "license": "MIT",
5438+ "dependencies": {
5439+ "linkifyjs": "^4.3.3"
5440+ },
5441+ "funding": {
5442+ "type": "github",
5443+ "url": "https://github.com/sponsors/ueberdosis"
5444+ },
5445+ "peerDependencies": {
5446+ "@tiptap/core": "3.31.4",
5447+ "@tiptap/pm": "3.31.4"
5448+ }
5449+ },
5450+ "node_modules/@tiptap/extension-list": {
5451+ "version": "3.31.4",
5452+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.4.tgz",
5453+ "integrity": "sha512-Wd4YRYxOdf+TtF33/QsRo1RK1CecVxjYGjUBIKNGHAywfh8tghgV4YOUCiV1iJqYt2IBs+RWlIqTmdIPzBrd4A==",
5454+ "license": "MIT",
5455+ "funding": {
5456+ "type": "github",
5457+ "url": "https://github.com/sponsors/ueberdosis"
5458+ },
5459+ "peerDependencies": {
5460+ "@tiptap/core": "3.31.4",
5461+ "@tiptap/pm": "3.31.4"
5462+ }
5463+ },
5464+ "node_modules/@tiptap/extension-list-item": {
5465+ "version": "3.31.4",
5466+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.4.tgz",
5467+ "integrity": "sha512-ao8XAPvhOrtGFILs+fKtOM91omDSpUC8X19m44gbaq5xJDVpIMZ0rP+NAVUS72G7LskhcHa0bu/Pm022bUVjVw==",
5468+ "license": "MIT",
5469+ "funding": {
5470+ "type": "github",
5471+ "url": "https://github.com/sponsors/ueberdosis"
5472+ },
5473+ "peerDependencies": {
5474+ "@tiptap/extension-list": "3.31.4"
5475+ }
5476+ },
5477+ "node_modules/@tiptap/extension-list-keymap": {
5478+ "version": "3.31.4",
5479+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.4.tgz",
5480+ "integrity": "sha512-etdkl/YnYfwtALDxecJ+4WYgJAez1fn1vzzZqJklZBpgqiZtny3NxpvWS7/JhsltNG84Wo/oovpfJgVmzhddrw==",
5481+ "license": "MIT",
5482+ "funding": {
5483+ "type": "github",
5484+ "url": "https://github.com/sponsors/ueberdosis"
5485+ },
5486+ "peerDependencies": {
5487+ "@tiptap/extension-list": "3.31.4"
5488+ }
5489+ },
5490+ "node_modules/@tiptap/extension-ordered-list": {
5491+ "version": "3.31.4",
5492+ "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.4.tgz",
5493+ "integrity": "sha512-UNWyF+Dhm08znszIMnT8Y2kJ1lvtiCvUGs1aG7m9cPHxDa7VycWnOqdtXWkc+6xRLNiC0zgWCFWgLLHduV8pRQ==",
5494+ "license": "MIT",
5495+ "funding": {
5496+ "type": "github",
5497+ "url": "https://github.com/sponsors/ueberdosis"
5498+ },
5499+ "peerDependencies": {
5500+ "@tiptap/extension-list": "3.31.4"
5501+ }
5502+ },
5503+ "node_modules/@tiptap/extension-paragraph": {
5504+ "version": "3.31.4",
5505+ "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.4.tgz",
5506+ "integrity": "sha512-WZlBgw+XVKXerRlmEsoQ7x1PwSU+dh3X+b70+ViZsLebdfsmmYBIaNDrvIGwG9UQr5eLnddJnZplMSTI2YKs+g==",
5507+ "license": "MIT",
5508+ "funding": {
5509+ "type": "github",
5510+ "url": "https://github.com/sponsors/ueberdosis"
5511+ },
5512+ "peerDependencies": {
5513+ "@tiptap/core": "3.31.4"
5514+ }
5515+ },
53085516 "node_modules/@tiptap/extension-strike": {
53095517 "version": "3.31.4",
53105518 "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.4.tgz",
54165624 "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
54175625 "license": "MIT"
54185626 },
5627+ "node_modules/@tiptap/starter-kit": {
5628+ "version": "3.31.4",
5629+ "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.4.tgz",
5630+ "integrity": "sha512-u7HIJ9vK6kFe4S4J2CIcDLrhRZzoTMSyU0XxjQ6lUNF7pWW3rMi//pvi8RcsZmxGjyb8CpXAqYgNl7f9WyaUeA==",
5631+ "license": "MIT",
5632+ "dependencies": {
5633+ "@tiptap/core": "3.31.4",
5634+ "@tiptap/extension-blockquote": "3.31.4",
5635+ "@tiptap/extension-bold": "3.31.4",
5636+ "@tiptap/extension-bullet-list": "3.31.4",
5637+ "@tiptap/extension-code": "3.31.4",
5638+ "@tiptap/extension-code-block": "3.31.4",
5639+ "@tiptap/extension-document": "3.31.4",
5640+ "@tiptap/extension-dropcursor": "3.31.4",
5641+ "@tiptap/extension-gapcursor": "3.31.4",
5642+ "@tiptap/extension-hard-break": "3.31.4",
5643+ "@tiptap/extension-heading": "3.31.4",
5644+ "@tiptap/extension-horizontal-rule": "3.31.4",
5645+ "@tiptap/extension-italic": "3.31.4",
5646+ "@tiptap/extension-link": "3.31.4",
5647+ "@tiptap/extension-list": "3.31.4",
5648+ "@tiptap/extension-list-item": "3.31.4",
5649+ "@tiptap/extension-list-keymap": "3.31.4",
5650+ "@tiptap/extension-ordered-list": "3.31.4",
5651+ "@tiptap/extension-paragraph": "3.31.4",
5652+ "@tiptap/extension-strike": "3.31.4",
5653+ "@tiptap/extension-text": "3.31.4",
5654+ "@tiptap/extension-underline": "3.31.4",
5655+ "@tiptap/extensions": "3.31.4",
5656+ "@tiptap/pm": "3.31.4"
5657+ },
5658+ "funding": {
5659+ "type": "github",
5660+ "url": "https://github.com/sponsors/ueberdosis"
5661+ }
5662+ },
54195663 "node_modules/@tootallnate/quickjs-emscripten": {
54205664 "version": "0.23.0",
54215665 "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
91139357 "unicode-trie": "^2.0.0"
91149358 }
91159359 },
9360+ "node_modules/linkifyjs": {
9361+ "version": "4.3.3",
9362+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
9363+ "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
9364+ "license": "MIT"
9365+ },
91169366 "node_modules/lodash": {
91179367 "version": "4.18.1",
91189368 "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",