authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 00:52:56-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 14:16:08-08:00
logbf7002209e6663d6c208c6dd0546277582077aea
treef0633b45fb5f815fc61389ffd8753ef3685f3b4a
parenta3aa4bd189721a87271e9d7a1bed0cb605bc5eee

Add Lexical-based composer editor with file mention pills

Introduce ComposerPromptEditor component using Lexical for rich text editing with @-mention support for files. Includes mention detection utilities and tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

7 files changed, 949 insertions(+), 133 deletions(-)

apps/web/package.json+2
......@@ -12,6 +12,7 @@
1212 },
1313 "dependencies": {
1414 "@base-ui/react": "^1.2.0",
15 "@lexical/react": "^0.41.0",
1516 "@pierre/diffs": "^1.1.0-beta.16",
1617 "@t3tools/contracts": "workspace:*",
1718 "@tanstack/react-pacer": "^0.19.4",
......@@ -22,6 +23,7 @@
2223 "@xterm/xterm": "^6.0.0",
2324 "class-variance-authority": "^0.7.1",
2425 "effect": "catalog:",
26 "lexical": "^0.41.0",
2527 "lucide-react": "^0.564.0",
2628 "react": "^19.0.0",
2729 "react-dom": "^19.0.0",
apps/web/src/components/ChatView.tsx+138-93
......@@ -37,6 +37,7 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer
3737import { isElectron } from "../env";
3838import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
3939import {
40 type ComposerTrigger,
4041 type ComposerTriggerKind,
4142 detectComposerTrigger,
4243 replaceTextRange,
......@@ -127,6 +128,7 @@ import {
127128} from "../composerDraftStore";
128129import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
129130import { clamp } from "effect/Number";
131import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
130132
131133function formatMessageMeta(createdAt: string, duration: string | null): string {
132134 if (!duration) return formatTimestamp(createdAt);
......@@ -458,10 +460,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
458460 const composerDraft = useComposerThreadDraft(threadId);
459461 const prompt = composerDraft.prompt;
460462 const composerImages = composerDraft.images;
461 const composerCursor = composerDraft.cursor;
462463 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
463464 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
464 const setComposerDraftCursor = useComposerDraftStore((store) => store.setCursor);
465465 const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);
466466 const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort);
467467 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
......@@ -500,6 +500,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
500500 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
501501 Record<string, string[]>
502502 >({});
503 const [composerCursor, setComposerCursor] = useState(() => prompt.length);
504 const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>
505 detectComposerTrigger(prompt, prompt.length),
506 );
503507 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<
504508 Record<string, string>
505509 >(() => readLastInvokedScriptByProjectFromStorage());
......@@ -507,10 +511,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
507511 const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
508512 const shouldAutoScrollRef = useRef(true);
509513 const pendingAutoScrollFrameRef = useRef<number | null>(null);
510 const textareaRef = useRef<HTMLTextAreaElement>(null);
514 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
515 const composerCommandInputRef = useRef<HTMLInputElement>(null);
511516 const composerFormRef = useRef<HTMLFormElement>(null);
512517 const composerFormHeightRef = useRef(0);
513518 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
519 const composerSelectLockRef = useRef(false);
520 const composerMenuOpenRef = useRef(false);
521 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
522 const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);
514523 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
515524 const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({});
516525 const sendInFlightRef = useRef(false);
......@@ -537,12 +546,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
537546 },
538547 [setComposerDraftPrompt, threadId],
539548 );
540 const setComposerCursor = useCallback(
541 (nextCursor: number) => {
542 setComposerDraftCursor(threadId, nextCursor);
543 },
544 [setComposerDraftCursor, threadId],
545 );
546549 const addComposerImage = useCallback(
547550 (image: ComposerImageAttachment) => {
548551 addComposerDraftImage(threadId, image);
......@@ -845,10 +848,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
845848 timelineEntries,
846849 ]);
847850 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
848 const composerTrigger = useMemo(
849 () => detectComposerTrigger(prompt, composerCursor),
850 [prompt, composerCursor],
851 );
852851 const composerTriggerKind = composerTrigger?.kind ?? null;
853852 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
854853 const isPathTrigger = composerTriggerKind === "path";
......@@ -927,6 +926,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
927926 null,
928927 [composerHighlightedItemId, composerMenuItems],
929928 );
929 composerMenuOpenRef.current = composerMenuOpen;
930 composerMenuItemsRef.current = composerMenuItems;
931 activeComposerMenuItemRef.current = activeComposerMenuItem;
930932 const nonPersistedComposerImageIdSet = useMemo(
931933 () => new Set(nonPersistedComposerImageIds),
932934 [nonPersistedComposerImageIds],
......@@ -1000,11 +1002,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
10001002 );
10011003
10021004 const focusComposer = useCallback(() => {
1003 const textarea = textareaRef.current;
1004 if (!textarea) return;
1005 textarea.focus();
1006 const cursor = textarea.value.length;
1007 textarea.setSelectionRange(cursor, cursor);
1005 composerEditorRef.current?.focusAtEnd();
10081006 }, []);
10091007 const scheduleComposerFocus = useCallback(() => {
10101008 window.requestAnimationFrame(() => {
......@@ -1434,8 +1432,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
14341432
14351433 useEffect(() => {
14361434 promptRef.current = prompt;
1435 setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length));
14371436 }, [prompt]);
14381437
1438 useEffect(() => {
1439 setOptimisticUserMessages((existing) => {
1440 for (const message of existing) {
1441 revokeUserMessagePreviewUrls(message);
1442 }
1443 return [];
1444 });
1445 setSendPhase("idle");
1446 setComposerHighlightedItemId(null);
1447 setComposerCursor(promptRef.current.length);
1448 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
1449 dragDepthRef.current = 0;
1450 setIsDragOverComposer(false);
1451 setExpandedImage(null);
1452 }, [threadId]);
1453
14391454 useEffect(() => {
14401455 let cancelled = false;
14411456 void (async () => {
......@@ -1557,14 +1572,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
15571572 ? (draftThread?.envMode ?? "local")
15581573 : "local";
15591574
1560 // Auto-resize textarea
1561 useEffect(() => {
1562 const ta = textareaRef.current;
1563 if (!ta) return;
1564 ta.style.height = "auto";
1565 ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
1566 }, [prompt]);
1567
15681575 useEffect(() => {
15691576 if (phase !== "running") return;
15701577 const timer = window.setInterval(() => {
......@@ -1727,7 +1734,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17271734 removeComposerImageFromDraft(imageId);
17281735 };
17291736
1730 const onComposerPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
1737 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
17311738 const files = Array.from(event.clipboardData.files);
17321739 if (files.length === 0) {
17331740 return;
......@@ -1826,8 +1833,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
18261833 [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
18271834 );
18281835
1829 const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
1830 e.preventDefault();
1836 const onSend = async (e?: { preventDefault: () => void }) => {
1837 e?.preventDefault();
18311838 const api = readNativeApi();
18321839 if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return;
18331840 const trimmed = prompt.trim();
......@@ -1894,6 +1901,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
18941901 promptRef.current = "";
18951902 clearComposerDraftContent(threadIdForSend);
18961903 setComposerHighlightedItemId(null);
1904 setComposerCursor(0);
1905 setComposerTrigger(null);
18971906
18981907 let createdServerThreadForLocalDraft = false;
18991908 let turnStartSucceeded = false;
......@@ -2050,8 +2059,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
20502059 });
20512060 promptRef.current = trimmed;
20522061 setPrompt(trimmed);
2053 addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));
20542062 setComposerCursor(trimmed.length);
2063 addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));
2064 setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length));
20552065 }
20562066 setThreadError(
20572067 threadIdForSend,
......@@ -2144,40 +2154,96 @@ export default function ChatView({ threadId }: ChatViewProps) {
21442154 );
21452155
21462156 const applyPromptReplacement = useCallback(
2147 (rangeStart: number, rangeEnd: number, replacement: string) => {
2157 (
2158 rangeStart: number,
2159 rangeEnd: number,
2160 replacement: string,
2161 options?: { expectedText?: string },
2162 ): boolean => {
2163 const currentText = promptRef.current;
2164 const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));
2165 const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd));
2166 if (
2167 options?.expectedText !== undefined &&
2168 currentText.slice(safeStart, safeEnd) !== options.expectedText
2169 ) {
2170 return false;
2171 }
21482172 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
21492173 promptRef.current = next.text;
21502174 setPrompt(next.text);
2175 setComposerCursor(next.cursor);
2176 setComposerTrigger(detectComposerTrigger(next.text, next.cursor));
21512177 window.requestAnimationFrame(() => {
2152 const textarea = textareaRef.current;
2153 if (!textarea) return;
2154 textarea.focus();
2155 textarea.setSelectionRange(next.cursor, next.cursor);
2156 setComposerCursor(next.cursor);
2178 composerEditorRef.current?.focusAt(next.cursor);
21572179 });
2180 return true;
21582181 },
2159 [setComposerCursor, setPrompt],
2182 [setPrompt],
21602183 );
21612184
2185 const readComposerSnapshot = useCallback((): { value: string; cursor: number } => {
2186 const editorSnapshot = composerEditorRef.current?.readSnapshot();
2187 if (editorSnapshot) {
2188 return editorSnapshot;
2189 }
2190 return { value: promptRef.current, cursor: composerCursor };
2191 }, [composerCursor]);
2192
2193 const resolveActiveComposerTrigger = useCallback((): {
2194 snapshot: { value: string; cursor: number };
2195 trigger: ComposerTrigger | null;
2196 } => {
2197 const snapshot = readComposerSnapshot();
2198 return {
2199 snapshot,
2200 trigger: detectComposerTrigger(snapshot.value, snapshot.cursor),
2201 };
2202 }, [readComposerSnapshot]);
2203
21622204 const onSelectComposerItem = useCallback(
21632205 (item: ComposerCommandItem) => {
2164 if (!composerTrigger) return;
2206 if (composerSelectLockRef.current) return;
2207 composerSelectLockRef.current = true;
2208 window.requestAnimationFrame(() => {
2209 composerSelectLockRef.current = false;
2210 });
2211 const { snapshot, trigger } = resolveActiveComposerTrigger();
2212 if (!trigger) return;
2213 const expectedToken = snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd);
21652214 if (item.type === "path") {
2166 applyPromptReplacement(
2167 composerTrigger.rangeStart,
2168 composerTrigger.rangeEnd,
2215 const applied = applyPromptReplacement(
2216 trigger.rangeStart,
2217 trigger.rangeEnd,
21692218 `@${item.path} `,
2219 { expectedText: expectedToken },
21702220 );
2221 if (applied) {
2222 setComposerHighlightedItemId(null);
2223 }
21712224 return;
21722225 }
21732226 if (item.type === "slash-command") {
2174 applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "/model ");
2227 const applied = applyPromptReplacement(
2228 trigger.rangeStart,
2229 trigger.rangeEnd,
2230 "/model ",
2231 { expectedText: expectedToken },
2232 );
2233 if (applied) {
2234 setComposerHighlightedItemId(null);
2235 }
21752236 return;
21762237 }
21772238 onModelSelect(item.model);
2178 applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "");
2239 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
2240 expectedText: expectedToken,
2241 });
2242 if (applied) {
2243 setComposerHighlightedItemId(null);
2244 }
21792245 },
2180 [applyPromptReplacement, composerTrigger, onModelSelect],
2246 [applyPromptReplacement, onModelSelect, resolveActiveComposerTrigger],
21812247 );
21822248 const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
21832249 setComposerHighlightedItemId(itemId);
......@@ -2206,41 +2272,41 @@ export default function ChatView({ threadId }: ChatViewProps) {
22062272 workspaceEntriesQuery.isLoading ||
22072273 workspaceEntriesQuery.isFetching);
22082274
2209 const onPromptChange = useCallback(
2210 (nextPrompt: string, nextCursor: number) => {
2211 promptRef.current = nextPrompt;
2212 setPrompt(nextPrompt);
2213 setComposerCursor(nextCursor);
2214 },
2215 [setComposerCursor, setPrompt],
2216 );
2275 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {
2276 promptRef.current = nextPrompt;
2277 setPrompt(nextPrompt);
2278 setComposerCursor(nextCursor);
2279 setComposerTrigger(detectComposerTrigger(nextPrompt, nextCursor));
2280 }, [setPrompt]);
2281
2282 const onComposerCommandKey = (key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent) => {
2283 const { trigger } = resolveActiveComposerTrigger();
2284 const menuIsActive = composerMenuOpenRef.current || trigger !== null;
22172285
2218 const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
2219 if (composerMenuOpen && composerMenuItems.length > 0) {
2220 if (e.key === "ArrowDown") {
2221 e.preventDefault();
2286 if (menuIsActive) {
2287 const currentItems = composerMenuItemsRef.current;
2288 if (key === "ArrowDown" && currentItems.length > 0) {
22222289 nudgeComposerMenuHighlight("ArrowDown");
2223 return;
2290 return true;
22242291 }
2225 if (e.key === "ArrowUp") {
2226 e.preventDefault();
2292 if (key === "ArrowUp" && currentItems.length > 0) {
22272293 nudgeComposerMenuHighlight("ArrowUp");
2228 return;
2294 return true;
22292295 }
2230 if (e.key === "Tab" || e.key === "Enter") {
2231 const selectedItem = activeComposerMenuItem ?? composerMenuItems[0];
2296 if (key === "Tab" || key === "Enter") {
2297 const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0];
22322298 if (selectedItem) {
2233 e.preventDefault();
22342299 onSelectComposerItem(selectedItem);
2235 return;
22362300 }
2301 return true;
22372302 }
22382303 }
22392304
2240 if (e.key === "Enter" && !e.shiftKey) {
2241 e.preventDefault();
2242 void onSend(e);
2305 if (key === "Enter" && !event.shiftKey) {
2306 void onSend();
2307 return true;
22432308 }
2309 return false;
22442310 };
22452311 const onToggleWorkGroup = useCallback((groupId: string) => {
22462312 setExpandedWorkGroups((existing) => ({
......@@ -2464,33 +2530,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
24642530 ))}
24652531 </div>
24662532 )}
2467 <textarea
2468 ref={textareaRef}
2469 className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none"
2470 rows={2}
2533 <ComposerPromptEditor
2534 ref={composerEditorRef}
24712535 value={prompt}
2472 onChange={(event) =>
2473 onPromptChange(
2474 event.target.value,
2475 event.target.selectionStart ?? event.target.value.length,
2476 )
2477 }
2478 onKeyDown={onKeyDown}
2479 onKeyUp={(event) =>
2480 setComposerCursor(
2481 event.currentTarget.selectionStart ?? event.currentTarget.value.length,
2482 )
2483 }
2484 onClick={(event) =>
2485 setComposerCursor(
2486 event.currentTarget.selectionStart ?? event.currentTarget.value.length,
2487 )
2488 }
2489 onSelect={(event) =>
2490 setComposerCursor(
2491 event.currentTarget.selectionStart ?? event.currentTarget.value.length,
2492 )
2493 }
2536 cursor={composerCursor}
2537 onChange={onPromptChange}
2538 onCommandKeyDown={onComposerCommandKey}
24942539 onPaste={onComposerPaste}
24952540 placeholder={
24962541 phase === "disconnected"
apps/web/src/components/ComposerPromptEditor.tsx created+664
......@@ -0,0 +1,664 @@
1import {
2 LexicalComposer,
3 type InitialConfigType,
4} from "@lexical/react/LexicalComposer";
5import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
6import { ContentEditable } from "@lexical/react/LexicalContentEditable";
7import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
8import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
9import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
10import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
11import {
12 $applyNodeReplacement,
13 $createRangeSelection,
14 $getSelection,
15 $setSelection,
16 $isElementNode,
17 $isLineBreakNode,
18 $isRangeSelection,
19 $isTextNode,
20 $createLineBreakNode,
21 $createParagraphNode,
22 $createTextNode,
23 KEY_ARROW_DOWN_COMMAND,
24 KEY_ARROW_UP_COMMAND,
25 KEY_ENTER_COMMAND,
26 KEY_TAB_COMMAND,
27 COMMAND_PRIORITY_HIGH,
28 KEY_BACKSPACE_COMMAND,
29 $getRoot,
30 type ElementNode,
31 type LexicalNode,
32 TextNode,
33 type EditorConfig,
34 type EditorState,
35 type NodeKey,
36 type SerializedTextNode,
37 type Spread,
38} from "lexical";
39import {
40 forwardRef,
41 useCallback,
42 useEffect,
43 useImperativeHandle,
44 useLayoutEffect,
45 useMemo,
46 useRef,
47 type ClipboardEventHandler,
48 type Ref,
49} from "react";
50
51import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
52import { cn } from "~/lib/utils";
53import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons";
54
55const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;
56
57type SerializedComposerMentionNode = Spread<
58 {
59 path: string;
60 type: "composer-mention";
61 version: 1;
62 },
63 SerializedTextNode
64>;
65
66class ComposerMentionNode extends TextNode {
67 __path: string;
68
69 static override getType(): string {
70 return "composer-mention";
71 }
72
73 static override clone(node: ComposerMentionNode): ComposerMentionNode {
74 return new ComposerMentionNode(node.__path, node.__key);
75 }
76
77 static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode {
78 return $createComposerMentionNode(serializedNode.path);
79 }
80
81 constructor(path: string, key?: NodeKey) {
82 const normalizedPath = path.startsWith("@") ? path.slice(1) : path;
83 super(`@${normalizedPath}`, key);
84 this.__path = normalizedPath;
85 }
86
87 override exportJSON(): SerializedComposerMentionNode {
88 return {
89 ...super.exportJSON(),
90 path: this.__path,
91 type: "composer-mention",
92 version: 1,
93 };
94 }
95
96 override createDOM(_config: EditorConfig): HTMLElement {
97 const dom = document.createElement("span");
98 dom.className =
99 "inline-flex select-none items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle";
100 renderMentionChipDom(dom, this.__path);
101 return dom;
102 }
103
104 override updateDOM(
105 prevNode: ComposerMentionNode,
106 dom: HTMLElement,
107 _config: EditorConfig,
108 ): boolean {
109 if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) {
110 renderMentionChipDom(dom, this.__path);
111 }
112 return false;
113 }
114
115 override canInsertTextBefore(): false {
116 return false;
117 }
118
119 override canInsertTextAfter(): false {
120 return false;
121 }
122
123 override isTextEntity(): true {
124 return true;
125 }
126
127 override isToken(): true {
128 return true;
129 }
130}
131
132function $createComposerMentionNode(path: string): ComposerMentionNode {
133 return $applyNodeReplacement(new ComposerMentionNode(path));
134}
135
136function inferMentionPathKind(pathValue: string): "file" | "directory" {
137 const base = basenameOfPath(pathValue);
138 if (base.startsWith(".") && !base.slice(1).includes(".")) {
139 return "directory";
140 }
141 if (base.includes(".")) {
142 return "file";
143 }
144 return "directory";
145}
146
147function renderMentionChipDom(container: HTMLElement, pathValue: string): void {
148 container.textContent = "";
149
150 const icon = document.createElement("img");
151 icon.alt = "";
152 icon.ariaHidden = "true";
153 icon.className = "size-3.5 shrink-0 opacity-85";
154 icon.loading = "lazy";
155 icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), "dark");
156
157 const label = document.createElement("span");
158 label.className = "truncate leading-tight";
159 label.textContent = basenameOfPath(pathValue);
160
161 container.append(icon, label);
162}
163
164function clampCursor(value: string, cursor: number): number {
165 if (!Number.isFinite(cursor)) return value.length;
166 return Math.max(0, Math.min(value.length, Math.floor(cursor)));
167}
168
169function getComposerNodeTextLength(node: LexicalNode): number {
170 if ($isTextNode(node)) {
171 return node.getTextContentSize();
172 }
173 if ($isLineBreakNode(node)) {
174 return 1;
175 }
176 if ($isElementNode(node)) {
177 return node.getChildren().reduce((total, child) => total + getComposerNodeTextLength(child), 0);
178 }
179 return 0;
180}
181
182function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number {
183 let offset = 0;
184 let current: LexicalNode | null = node;
185
186 while (current) {
187 const nextParent = current.getParent() as LexicalNode | null;
188 if (!nextParent || !$isElementNode(nextParent)) {
189 break;
190 }
191 const siblings = nextParent.getChildren();
192 const index = current.getIndexWithinParent();
193 for (let i = 0; i < index; i += 1) {
194 const sibling = siblings[i];
195 if (!sibling) continue;
196 offset += getComposerNodeTextLength(sibling);
197 }
198 current = nextParent;
199 }
200
201 if ($isTextNode(node)) {
202 return offset + Math.min(pointOffset, node.getTextContentSize());
203 }
204
205 if ($isLineBreakNode(node)) {
206 return offset + Math.min(pointOffset, 1);
207 }
208
209 if ($isElementNode(node)) {
210 const children = node.getChildren();
211 const clampedOffset = Math.max(0, Math.min(pointOffset, children.length));
212 for (let i = 0; i < clampedOffset; i += 1) {
213 const child = children[i];
214 if (!child) continue;
215 offset += getComposerNodeTextLength(child);
216 }
217 return offset;
218 }
219
220 return offset;
221}
222
223function findSelectionPointAtOffset(
224 node: LexicalNode,
225 remainingRef: { value: number },
226): { key: string; offset: number; type: "text" | "element" } | null {
227 if ($isTextNode(node)) {
228 const size = node.getTextContentSize();
229 if (remainingRef.value <= size) {
230 return {
231 key: node.getKey(),
232 offset: remainingRef.value,
233 type: "text",
234 };
235 }
236 remainingRef.value -= size;
237 return null;
238 }
239
240 if ($isLineBreakNode(node)) {
241 const parent = node.getParent();
242 if (!parent) return null;
243 const index = node.getIndexWithinParent();
244 if (remainingRef.value === 0) {
245 return {
246 key: parent.getKey(),
247 offset: index,
248 type: "element",
249 };
250 }
251 if (remainingRef.value === 1) {
252 return {
253 key: parent.getKey(),
254 offset: index + 1,
255 type: "element",
256 };
257 }
258 remainingRef.value -= 1;
259 return null;
260 }
261
262 if ($isElementNode(node)) {
263 const children = node.getChildren();
264 for (const child of children) {
265 const point = findSelectionPointAtOffset(child, remainingRef);
266 if (point) {
267 return point;
268 }
269 }
270 if (remainingRef.value === 0) {
271 return {
272 key: node.getKey(),
273 offset: children.length,
274 type: "element",
275 };
276 }
277 }
278
279 return null;
280}
281
282function $setSelectionAtComposerOffset(nextOffset: number): void {
283 const root = $getRoot();
284 const rootText = root.getTextContent();
285 const boundedOffset = clampCursor(rootText, nextOffset);
286 const remainingRef = { value: boundedOffset };
287 const point =
288 findSelectionPointAtOffset(root, remainingRef) ?? {
289 key: root.getKey(),
290 offset: root.getChildren().length,
291 type: "element" as const,
292 };
293 const selection = $createRangeSelection();
294 selection.anchor.set(point.key, point.offset, point.type);
295 selection.focus.set(point.key, point.offset, point.type);
296 $setSelection(selection);
297}
298
299function $readSelectionOffsetFromEditorState(fallback: number): number {
300 const selection = $getSelection();
301 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
302 return fallback;
303 }
304 const rootText = $getRoot().getTextContent();
305 const anchorNode = selection.anchor.getNode();
306 return clampCursor(rootText, getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset));
307}
308
309function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {
310 const lines = text.split("\n");
311 for (let index = 0; index < lines.length; index += 1) {
312 const line = lines[index] ?? "";
313 if (line.length > 0) {
314 parent.append($createTextNode(line));
315 }
316 if (index < lines.length - 1) {
317 parent.append($createLineBreakNode());
318 }
319 }
320}
321
322function $setComposerEditorPrompt(prompt: string): void {
323 const root = $getRoot();
324 root.clear();
325 const paragraph = $createParagraphNode();
326 root.append(paragraph);
327
328 const segments = splitPromptIntoComposerSegments(prompt);
329 for (const segment of segments) {
330 if (segment.type === "mention") {
331 paragraph.append($createComposerMentionNode(segment.path));
332 continue;
333 }
334 $appendTextWithLineBreaks(paragraph, segment.text);
335 }
336}
337
338export interface ComposerPromptEditorHandle {
339 focus: () => void;
340 focusAt: (cursor: number) => void;
341 focusAtEnd: () => void;
342 readSnapshot: () => { value: string; cursor: number };
343}
344
345interface ComposerPromptEditorProps {
346 value: string;
347 cursor: number;
348 disabled: boolean;
349 placeholder: string;
350 className?: string;
351 onChange: (nextValue: string, nextCursor: number) => void;
352 onCommandKeyDown?: (
353 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
354 event: KeyboardEvent,
355 ) => boolean;
356 onPaste: ClipboardEventHandler<HTMLElement>;
357}
358
359interface ComposerPromptEditorInnerProps extends ComposerPromptEditorProps {
360 editorRef: Ref<ComposerPromptEditorHandle>;
361}
362
363function ComposerCommandKeyPlugin(props: {
364 onCommandKeyDown?: (
365 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
366 event: KeyboardEvent,
367 ) => boolean;
368}) {
369 const [editor] = useLexicalComposerContext();
370
371 useEffect(() => {
372 const handleCommand = (
373 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
374 event: KeyboardEvent | null,
375 ): boolean => {
376 if (!props.onCommandKeyDown || !event) {
377 return false;
378 }
379 const handled = props.onCommandKeyDown(key, event);
380 if (handled) {
381 event.preventDefault();
382 event.stopPropagation();
383 }
384 return handled;
385 };
386
387 const unregisterArrowDown = editor.registerCommand(
388 KEY_ARROW_DOWN_COMMAND,
389 (event) => handleCommand("ArrowDown", event),
390 COMMAND_PRIORITY_HIGH,
391 );
392 const unregisterArrowUp = editor.registerCommand(
393 KEY_ARROW_UP_COMMAND,
394 (event) => handleCommand("ArrowUp", event),
395 COMMAND_PRIORITY_HIGH,
396 );
397 const unregisterEnter = editor.registerCommand(
398 KEY_ENTER_COMMAND,
399 (event) => handleCommand("Enter", event),
400 COMMAND_PRIORITY_HIGH,
401 );
402 const unregisterTab = editor.registerCommand(
403 KEY_TAB_COMMAND,
404 (event) => handleCommand("Tab", event),
405 COMMAND_PRIORITY_HIGH,
406 );
407
408 return () => {
409 unregisterArrowDown();
410 unregisterArrowUp();
411 unregisterEnter();
412 unregisterTab();
413 };
414 }, [editor, props]);
415
416 return null;
417}
418
419function ComposerMentionBackspacePlugin() {
420 const [editor] = useLexicalComposerContext();
421
422 useEffect(() => {
423 return editor.registerCommand(
424 KEY_BACKSPACE_COMMAND,
425 (event) => {
426 const selection = $getSelection();
427 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
428 return false;
429 }
430
431 const anchorNode = selection.anchor.getNode();
432 const removeMentionNode = (candidate: unknown): boolean => {
433 if (!(candidate instanceof ComposerMentionNode)) {
434 return false;
435 }
436 const mentionStart = getAbsoluteOffsetForPoint(candidate, 0);
437 candidate.remove();
438 $setSelectionAtComposerOffset(mentionStart);
439 event?.preventDefault();
440 return true;
441 };
442
443 if (removeMentionNode(anchorNode)) {
444 return true;
445 }
446
447 if ($isTextNode(anchorNode)) {
448 if (selection.anchor.offset > 0) {
449 return false;
450 }
451 if (removeMentionNode(anchorNode.getPreviousSibling())) {
452 return true;
453 }
454 const parent = anchorNode.getParent();
455 if ($isElementNode(parent)) {
456 const index = anchorNode.getIndexWithinParent();
457 if (index > 0 && removeMentionNode(parent.getChildAtIndex(index - 1))) {
458 return true;
459 }
460 }
461 return false;
462 }
463
464 if ($isElementNode(anchorNode)) {
465 const childIndex = selection.anchor.offset - 1;
466 if (childIndex >= 0 && removeMentionNode(anchorNode.getChildAtIndex(childIndex))) {
467 return true;
468 }
469 }
470
471 return false;
472 },
473 COMMAND_PRIORITY_HIGH,
474 );
475 }, [editor]);
476
477 return null;
478}
479
480function ComposerPromptEditorInner({
481 value,
482 cursor,
483 disabled,
484 placeholder,
485 className,
486 onChange,
487 onCommandKeyDown,
488 onPaste,
489 editorRef,
490}: ComposerPromptEditorInnerProps) {
491 const [editor] = useLexicalComposerContext();
492 const onChangeRef = useRef(onChange);
493 const snapshotRef = useRef({ value, cursor: clampCursor(value, cursor) });
494
495 useEffect(() => {
496 onChangeRef.current = onChange;
497 }, [onChange]);
498
499 useEffect(() => {
500 editor.setEditable(!disabled);
501 }, [disabled, editor]);
502
503 useLayoutEffect(() => {
504 const normalizedCursor = clampCursor(value, cursor);
505 const previousSnapshot = snapshotRef.current;
506 if (previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor) {
507 return;
508 }
509
510 if (previousSnapshot.value !== value) {
511 editor.update(() => {
512 $setComposerEditorPrompt(value);
513 });
514 }
515
516 snapshotRef.current = { value, cursor: normalizedCursor };
517
518 const rootElement = editor.getRootElement();
519 if (!rootElement || document.activeElement !== rootElement) {
520 return;
521 }
522
523 editor.update(() => {
524 $setSelectionAtComposerOffset(normalizedCursor);
525 });
526 }, [cursor, editor, value]);
527
528 const focusAt = useCallback(
529 (nextCursor: number) => {
530 const rootElement = editor.getRootElement();
531 if (!rootElement) return;
532 const boundedCursor = clampCursor(snapshotRef.current.value, nextCursor);
533 rootElement.focus();
534 editor.update(() => {
535 $setSelectionAtComposerOffset(boundedCursor);
536 });
537 snapshotRef.current = {
538 value: snapshotRef.current.value,
539 cursor: boundedCursor,
540 };
541 onChangeRef.current(snapshotRef.current.value, boundedCursor);
542 },
543 [editor],
544 );
545
546 const readSnapshot = useCallback((): { value: string; cursor: number } => {
547 let snapshot = snapshotRef.current;
548 editor.getEditorState().read(() => {
549 const nextValue = $getRoot().getTextContent();
550 const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor);
551 const nextCursor = clampCursor(
552 nextValue,
553 $readSelectionOffsetFromEditorState(fallbackCursor),
554 );
555 snapshot = {
556 value: nextValue,
557 cursor: nextCursor,
558 };
559 });
560 snapshotRef.current = snapshot;
561 return snapshot;
562 }, [editor]);
563
564 useImperativeHandle(
565 editorRef,
566 () => ({
567 focus: () => {
568 focusAt(snapshotRef.current.cursor);
569 },
570 focusAt: (nextCursor: number) => {
571 focusAt(nextCursor);
572 },
573 focusAtEnd: () => {
574 focusAt(snapshotRef.current.value.length);
575 },
576 readSnapshot,
577 }),
578 [focusAt, readSnapshot],
579 );
580
581 const handleEditorChange = useCallback((editorState: EditorState) => {
582 editorState.read(() => {
583 const nextValue = $getRoot().getTextContent();
584 const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor);
585 const nextCursor = clampCursor(nextValue, $readSelectionOffsetFromEditorState(fallbackCursor));
586 const previousSnapshot = snapshotRef.current;
587 if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) {
588 return;
589 }
590 snapshotRef.current = {
591 value: nextValue,
592 cursor: nextCursor,
593 };
594 onChangeRef.current(nextValue, nextCursor);
595 });
596 }, []);
597
598 return (
599 <div className="relative">
600 <PlainTextPlugin
601 contentEditable={
602 <ContentEditable
603 className={cn(
604 "block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-[14px] leading-relaxed text-foreground focus:outline-none",
605 className,
606 )}
607 aria-placeholder={placeholder}
608 placeholder={<span />}
609 onPaste={onPaste}
610 />
611 }
612 placeholder={
613 <div className="pointer-events-none absolute inset-0 text-[14px] leading-relaxed text-muted-foreground/35">
614 {placeholder}
615 </div>
616 }
617 ErrorBoundary={LexicalErrorBoundary}
618 />
619 <OnChangePlugin onChange={handleEditorChange} />
620 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />
621 <ComposerMentionBackspacePlugin />
622 <HistoryPlugin />
623 </div>
624 );
625}
626
627export const ComposerPromptEditor = forwardRef<ComposerPromptEditorHandle, ComposerPromptEditorProps>(
628 function ComposerPromptEditor(
629 { value, cursor, disabled, placeholder, className, onChange, onCommandKeyDown, onPaste },
630 ref,
631 ) {
632 const initialValueRef = useRef(value);
633 const initialConfig = useMemo<InitialConfigType>(
634 () => ({
635 namespace: "t3tools-composer-editor",
636 editable: true,
637 nodes: [ComposerMentionNode],
638 editorState: () => {
639 $setComposerEditorPrompt(initialValueRef.current);
640 },
641 onError: (error) => {
642 throw error;
643 },
644 }),
645 [],
646 );
647
648 return (
649 <LexicalComposer key={COMPOSER_EDITOR_HMR_KEY} initialConfig={initialConfig}>
650 <ComposerPromptEditorInner
651 value={value}
652 cursor={cursor}
653 disabled={disabled}
654 placeholder={placeholder}
655 onChange={onChange}
656 onPaste={onPaste}
657 editorRef={ref}
658 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}
659 {...(className ? { className } : {})}
660 />
661 </LexicalComposer>
662 );
663 },
664);
apps/web/src/composer-editor-mentions.test.ts created+27
......@@ -0,0 +1,27 @@
1import { describe, expect, it } from "vitest";
2
3import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
4
5describe("splitPromptIntoComposerSegments", () => {
6 it("splits mention tokens followed by whitespace into mention segments", () => {
7 expect(splitPromptIntoComposerSegments("Inspect @AGENTS.md please")).toEqual([
8 { type: "text", text: "Inspect " },
9 { type: "mention", path: "AGENTS.md" },
10 { type: "text", text: " please" },
11 ]);
12 });
13
14 it("does not convert an incomplete trailing mention token", () => {
15 expect(splitPromptIntoComposerSegments("Inspect @AGENTS.md")).toEqual([
16 { type: "text", text: "Inspect @AGENTS.md" },
17 ]);
18 });
19
20 it("keeps newlines around mention tokens", () => {
21 expect(splitPromptIntoComposerSegments("one\n@src/index.ts \ntwo")).toEqual([
22 { type: "text", text: "one\n" },
23 { type: "mention", path: "src/index.ts" },
24 { type: "text", text: " \ntwo" },
25 ]);
26 });
27});
apps/web/src/composer-editor-mentions.ts created+56
......@@ -0,0 +1,56 @@
1export type ComposerPromptSegment =
2 | {
3 type: "text";
4 text: string;
5 }
6 | {
7 type: "mention";
8 path: string;
9 };
10
11const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;
12
13function pushTextSegment(segments: ComposerPromptSegment[], text: string): void {
14 if (!text) return;
15 const last = segments[segments.length - 1];
16 if (last && last.type === "text") {
17 last.text += text;
18 return;
19 }
20 segments.push({ type: "text", text });
21}
22
23export function splitPromptIntoComposerSegments(prompt: string): ComposerPromptSegment[] {
24 const segments: ComposerPromptSegment[] = [];
25 if (!prompt) {
26 return segments;
27 }
28
29 let cursor = 0;
30 for (const match of prompt.matchAll(MENTION_TOKEN_REGEX)) {
31 const fullMatch = match[0];
32 const prefix = match[1] ?? "";
33 const path = match[2] ?? "";
34 const matchIndex = match.index ?? 0;
35 const mentionStart = matchIndex + prefix.length;
36 const mentionEnd = mentionStart + fullMatch.length - prefix.length;
37
38 if (mentionStart > cursor) {
39 pushTextSegment(segments, prompt.slice(cursor, mentionStart));
40 }
41
42 if (path.length > 0) {
43 segments.push({ type: "mention", path });
44 } else {
45 pushTextSegment(segments, prompt.slice(mentionStart, mentionEnd));
46 }
47
48 cursor = mentionEnd;
49 }
50
51 if (cursor < prompt.length) {
52 pushTextSegment(segments, prompt.slice(cursor));
53 }
54
55 return segments;
56}
apps/web/src/composerDraftStore.ts-40
......@@ -28,7 +28,6 @@ export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "prev
2828
2929interface PersistedComposerThreadDraftState {
3030 prompt: string;
31 cursor: number;
3231 attachments: PersistedComposerImageAttachment[];
3332 model?: string | null;
3433 effort?: ReasoningEffort | null;
......@@ -50,7 +49,6 @@ interface PersistedComposerDraftStoreState {
5049
5150interface ComposerThreadDraftState {
5251 prompt: string;
53 cursor: number;
5452 images: ComposerImageAttachment[];
5553 nonPersistedImageIds: string[];
5654 persistedAttachments: PersistedComposerImageAttachment[];
......@@ -100,7 +98,6 @@ interface ComposerDraftStoreState {
10098 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
10199 clearDraftThread: (threadId: ThreadId) => void;
102100 setPrompt: (threadId: ThreadId, prompt: string) => void;
103 setCursor: (threadId: ThreadId, cursor: number) => void;
104101 setModel: (threadId: ThreadId, model: string | null | undefined) => void;
105102 setEffort: (threadId: ThreadId, effort: ReasoningEffort | null | undefined) => void;
106103 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;
......@@ -129,7 +126,6 @@ Object.freeze(EMPTY_IDS);
129126Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
130127const EMPTY_THREAD_DRAFT = Object.freeze({
131128 prompt: "",
132 cursor: 0,
133129 images: EMPTY_IMAGES,
134130 nonPersistedImageIds: EMPTY_IDS,
135131 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
......@@ -142,7 +138,6 @@ const REASONING_EFFORT_VALUES = new Set<ReasoningEffort>(REASONING_OPTIONS);
142138function createEmptyThreadDraft(): ComposerThreadDraftState {
143139 return {
144140 prompt: "",
145 cursor: 0,
146141 images: [],
147142 nonPersistedImageIds: [],
148143 persistedAttachments: [],
......@@ -304,10 +299,6 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
304299 }
305300 const draftCandidate = draftValue as Record<string, unknown>;
306301 const prompt = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : "";
307 const cursor =
308 typeof draftCandidate.cursor === "number" && Number.isFinite(draftCandidate.cursor)
309 ? Math.max(0, Math.floor(draftCandidate.cursor))
310 : 0;
311302 const attachments = Array.isArray(draftCandidate.attachments)
312303 ? draftCandidate.attachments.flatMap((entry) => {
313304 const normalized = normalizePersistedAttachment(entry);
......@@ -327,7 +318,6 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
327318 }
328319 nextDraftsByThreadId[threadId as ThreadId] = {
329320 prompt,
330 cursor,
331321 attachments,
332322 ...(model ? { model } : {}),
333323 ...(effort ? { effort } : {}),
......@@ -429,7 +419,6 @@ function toHydratedThreadDraft(
429419): ComposerThreadDraftState {
430420 return {
431421 prompt: persistedDraft.prompt,
432 cursor: persistedDraft.cursor,
433422 images: hydrateImagesFromPersisted(persistedDraft.attachments),
434423 nonPersistedImageIds: [],
435424 persistedAttachments: persistedDraft.attachments,
......@@ -672,36 +661,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
672661 }
673662 set((state) => {
674663 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
675 const cursor = Math.min(existing.cursor, prompt.length);
676664 const nextDraft: ComposerThreadDraftState = {
677665 ...existing,
678666 prompt,
679 cursor,
680 };
681 const nextDraftsByThreadId = { ...state.draftsByThreadId };
682 if (shouldRemoveDraft(nextDraft)) {
683 delete nextDraftsByThreadId[threadId];
684 } else {
685 nextDraftsByThreadId[threadId] = nextDraft;
686 }
687 return { draftsByThreadId: nextDraftsByThreadId };
688 });
689 },
690 setCursor: (threadId, cursor) => {
691 if (threadId.length === 0) {
692 return;
693 }
694 const nextCursor = Number.isFinite(cursor) ? Math.max(0, Math.floor(cursor)) : 0;
695 set((state) => {
696 const existing = state.draftsByThreadId[threadId];
697 if (!existing && nextCursor === 0) {
698 return state;
699 }
700 const base = existing ?? createEmptyThreadDraft();
701 const boundedCursor = Math.min(nextCursor, base.prompt.length);
702 const nextDraft: ComposerThreadDraftState = {
703 ...base,
704 cursor: boundedCursor,
705667 };
706668 const nextDraftsByThreadId = { ...state.draftsByThreadId };
707669 if (shouldRemoveDraft(nextDraft)) {
......@@ -939,7 +901,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
939901 const nextDraft: ComposerThreadDraftState = {
940902 ...current,
941903 prompt: "",
942 cursor: 0,
943904 images: [],
944905 nonPersistedImageIds: [],
945906 persistedAttachments: [],
......@@ -1009,7 +970,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1009970 }
1010971 const persistedDraft: PersistedComposerThreadDraftState = {
1011972 prompt: draft.prompt,
1012 cursor: Math.min(Math.max(0, draft.cursor), draft.prompt.length),
1013973 attachments: draft.persistedAttachments,
1014974 };
1015975 if (draft.model) {
bun.lock+62
......@@ -63,6 +63,7 @@
6363 "version": "0.0.0-alpha.4",
6464 "dependencies": {
6565 "@base-ui/react": "^1.2.0",
66 "@lexical/react": "^0.41.0",
6667 "@pierre/diffs": "^1.1.0-beta.16",
6768 "@t3tools/contracts": "workspace:*",
6869 "@tanstack/react-pacer": "^0.19.4",
......@@ -73,6 +74,7 @@
7374 "@xterm/xterm": "^6.0.0",
7475 "class-variance-authority": "^0.7.1",
7576 "effect": "catalog:",
77 "lexical": "^0.41.0",
7678 "lucide-react": "^0.564.0",
7779 "react": "^19.0.0",
7880 "react-dom": "^19.0.0",
......@@ -238,6 +240,8 @@
238240
239241 "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
240242
243 "@floating-ui/react": ["@floating-ui/react@0.27.18", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.7", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-xJWJxvmy3a05j643gQt+pRbht5XnTlGpsEsAPnMi5F5YTOEEJymA90uZKBD8OvIv5XvZ1qi4GcccSlqT3Bq44Q=="],
244
241245 "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="],
242246
243247 "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
......@@ -276,6 +280,50 @@
276280
277281 "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
278282
283 "@lexical/clipboard": ["@lexical/clipboard@0.41.0", "", { "dependencies": { "@lexical/html": "0.41.0", "@lexical/list": "0.41.0", "@lexical/selection": "0.41.0", "@lexical/utils": "0.41.0", "lexical": "0.41.0" } }, "sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA=="],
284
285 "@lexical/code": ["@lexical/code@0.41.0", "", { "dependencies": { "@lexical/utils": "0.41.0", "lexical": "0.41.0", "prismjs": "^1.30.0" } }, "sha512-0hoNi1KC9/N3SBOGcOcFqnT0OpwmcRRAhfxTKMGqfCtCvAMzULVwZ8RWc9/NV9bKYESgBTW5D9xkDANP2mspHg=="],
286
287 "@lexical/devtools-core": ["@lexical/devtools-core@0.41.0", "", { "dependencies": { "@lexical/html": "0.41.0", "@lexical/link": "0.41.0", "@lexical/mark": "0.41.0", "@lexical/table": "0.41.0", "@lexical/utils": "0.41.0", "lexical": "0.41.0" }, "peerDependencies": { "react": ">=17.x", "react-dom": ">=17.x" } }, "sha512-FzJtluBhBc8bKS11TUZe72KoZN/hnzIyiiM0SPJAsPwGpoXuM01jqpXQGybWf/1bWB+bmmhOae7O4Nywi/Csuw=="],
288
289 "@lexical/dragon": ["@lexical/dragon@0.41.0", "", { "dependencies": { "@lexical/extension": "0.41.0", "lexical": "0.41.0" } }, "sha512-gBEqkk8Q6ZPruvDaRcOdF1EK9suCVBODzOCcR+EnoJTaTjfDkCM7pkPAm4w90Wa1wCZEtFHvCfas+jU9MDSumg=="],
290
291 "@lexical/extension": ["@lexical/extension@0.41.0", "", { "dependencies": { "@lexical/utils": "0.41.0", "@preact/signals-core": "^1.11.0", "lexical": "0.41.0" } }, "sha512-sF4SPiP72yXvIGchmmIZ7Yg2XZTxNLOpFEIIzdqG7X/1fa1Ham9P/T7VbrblWpF6Ei5LJtK9JgNVB0hb4l3o1g=="],
292
293 "@lexical/hashtag": ["@lexical/hashtag@0.41.0", "", { "dependencies": { "@lexical/text": "0.41.0", "@lexical/utils": "0.41.0", "lexical": "0.41.0" } }, "sha512-tFWM74RW4KU0E/sj2aowfWl26vmLUTp331CgVESnhQKcZBfT40KJYd57HEqBDTfQKn4MUhylQCCA0hbpw6EeFQ=="],
294
295 "@lexical/history": ["@lexical/history@0.41.0", "", { "dependencies": { "@lexical/extension": "0.41.0", "@lexical/utils": "0.41.0", "lexical": "0.41.0" } }, "sha512-kGoVWsiOn62+RMjRolRa+NXZl8jFwxav6GNDiHH8yzivtoaH8n1SwUfLJELXCzeqzs81HySqD4q30VLJVTGoDg=="],
296
297 "@lexical/html": ["@lexical/html@0.41.0", "", { "dependencies": { "@lexical/selection": "0.41.0", "@lexical/utils": "0.41.0", "lexical": "0.41.0" } }, "sha512-3RyZy+H/IDKz2D66rNN/NqYx87xVFrngfEbyu1OWtbY963RUFnopiVHCQvsge/8kT04QSZ7U/DzjVFqeNS6clg=="],
298
299 "@lexical/link": ["@lexical/link@0.41.0", "", { "dependencies": { "@lexical/extension": "0.41.0", "@lexical/utils": "0.41.0", "lexical": "0.41.0" } }, "sha512-Rjtx5cGWAkKcnacncbVsZ1TqRnUB2Wm4eEVKpaAEG41+kHgqghzM2P+UGT15yROroxJu8KvAC9ISiYFiU4XE1w=="],
300
301 "@lexical/list": ["@lexical/list@0.41.0", "", { "dependencies": { "@lexical/extension": "0.41.0", "@lexical/selection": "0.41.0", "@lexical/utils": "0.41.0", "lexical": "0.41.0" } }, "sha512-RXvB+xcbzVoQLGRDOBRCacztG7V+bI95tdoTwl8pz5xvgPtAaRnkZWMDP+yMNzMJZsqEChdtpxbf0NgtMkun6g=="],
302
303 "@lexical/mark": ["@lexical/mark@0.41.0", "", { "dependencies": { "@lexical/utils": "0.41.0", "lexical": "0.41.0" } }, "sha512-UO5WVs9uJAYIKHSlYh4Z1gHrBBchTOi21UCYBIZ7eAs4suK84hPzD+3/LAX5CB7ZltL6ke5Sly3FOwNXv/wfpA=="],
304
305 "@lexical/markdown": ["@lexical/markdown@0.41.0", "", { "dependencies": { "@lexical/code": "0.41.0", "@lexical/link": "0.41.0", "@lexical/list": "0.41.0", "@lexical/rich-text": "0.41.0", "@lexical/text": "0.41.0", "@lexical/utils": "0.41.0", "lexical": "0.41.0" } }, "sha512-bzI73JMXpjGFhqUWNV6KqfjWcgAWzwFT+J3RHtbCF5rysC8HLldBYojOgAAtPfXqfxyv2mDzsY7SoJ75s9uHZA=="],
306
307 "@lexical/offset": ["@lexical/offset@0.41.0", "", { "dependencies": { "lexical": "0.41.0" } }, "sha512-2RHBXZqC8gm3X9C0AyRb0M8w7zJu5dKiasrif+jSKzsxPjAUeF1m95OtIOsWs1XLNUgASOSUqGovDZxKJslZfA=="],
308
309 "@lexical/overflow": ["@lexical/overflow@0.41.0", "", { "dependencies": { "lexical": "0.41.0" } }, "sha512-Iy6ZiJip8X14EBYt1zKPOrXyQ4eG9JLBEoPoSVBTiSbVd+lYicdUvaOThT0k0/qeVTN9nqTaEltBjm56IrVKCQ=="],
310
311 "@lexical/plain-text": ["@lexical/plain-text@0.41.0", "", { "dependencies": { "@lexical/clipboard": "0.41.0", "@lexical/dragon": "0.41.0", "@lexical/selection": "0.41.0", "@lexical/utils": "0.41.0", "lexical": "0.41.0" } }, "sha512-HIsGgmFUYRUNNyvckun33UQfU7LRzDlxymHUq67+Bxd5bXqdZOrStEKJXuDX+LuLh/GXZbaWNbDLqwLBObfbQg=="],
312
313 "@lexical/react": ["@lexical/react@0.41.0", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "@lexical/devtools-core": "0.41.0", "@lexical/dragon": "0.41.0", "@lexical/extension": "0.41.0", "@lexical/hashtag": "0.41.0", "@lexical/history": "0.41.0", "@lexical/link": "0.41.0", "@lexical/list": "0.41.0", "@lexical/mark": "0.41.0", "@lexical/markdown": "0.41.0", "@lexical/overflow": "0.41.0", "@lexical/plain-text": "0.41.0", "@lexical/rich-text": "0.41.0", "@lexical/table": "0.41.0", "@lexical/text": "0.41.0", "@lexical/utils": "0.41.0", "@lexical/yjs": "0.41.0", "lexical": "0.41.0", "react-error-boundary": "^6.0.0" }, "peerDependencies": { "react": ">=17.x", "react-dom": ">=17.x" } }, "sha512-7+GUdZUm6sofWm+zdsWAs6cFBwKNsvsHezZTrf6k8jrZxL461ZQmbz/16b4DvjCGL9r5P1fR7md9/LCmk8TiCg=="],
314
315 "@lexical/rich-text": ["@lexical/rich-text@0.41.0", "", { "dependencies": { "@lexical/clipboard": "0.41.0", "@lexical/dragon": "0.41.0", "@lexical/selection": "0.41.0", "@lexical/utils": "0.41.0", "lexical": "0.41.0" } }, "sha512-yUcr7ZaaVTZNi8bow4CK1M8jy2qyyls1Vr+5dVjwBclVShOL/F/nFyzBOSb6RtXXRbd3Ahuk9fEleppX/RNIdw=="],
316
317 "@lexical/selection": ["@lexical/selection@0.41.0", "", { "dependencies": { "lexical": "0.41.0" } }, "sha512-1s7/kNyRzcv5uaTwsUL28NpiisqTf5xZ1zNukLsCN1xY+TWbv9RE9OxIv+748wMm4pxNczQe/UbIBODkbeknLw=="],
318
319 "@lexical/table": ["@lexical/table@0.41.0", "", { "dependencies": { "@lexical/clipboard": "0.41.0", "@lexical/extension": "0.41.0", "@lexical/utils": "0.41.0", "lexical": "0.41.0" } }, "sha512-d3SPThBAr+oZ8O74TXU0iXM3rLbrAVC7/HcOnSAq7/AhWQW8yMutT51JQGN+0fMLP9kqoWSAojNtkdvzXfU/+A=="],
320
321 "@lexical/text": ["@lexical/text@0.41.0", "", { "dependencies": { "lexical": "0.41.0" } }, "sha512-gGA+Anc7ck110EXo4KVKtq6Ui3M7Vz3OpGJ4QE6zJHWW8nV5h273koUGSutAMeoZgRVb6t01Izh3ORoFt/j1CA=="],
322
323 "@lexical/utils": ["@lexical/utils@0.41.0", "", { "dependencies": { "@lexical/selection": "0.41.0", "lexical": "0.41.0" } }, "sha512-Wlsokr5NQCq83D+7kxZ9qs5yQ3dU3Qaf2M+uXxLRoPoDaXqW8xTWZq1+ZFoEzsHzx06QoPa4Vu/40BZR91uQPg=="],
324
325 "@lexical/yjs": ["@lexical/yjs@0.41.0", "", { "dependencies": { "@lexical/offset": "0.41.0", "@lexical/selection": "0.41.0", "lexical": "0.41.0" }, "peerDependencies": { "yjs": ">=13.5.22" } }, "sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w=="],
326
279327 "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
280328
281329 "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
......@@ -380,6 +428,8 @@
380428
381429 "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="],
382430
431 "@preact/signals-core": ["@preact/signals-core@1.13.0", "", {}, "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg=="],
432
383433 "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
384434
385435 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.3", "", { "os": "android", "cpu": "arm64" }, "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ=="],
......@@ -922,6 +972,8 @@
922972
923973 "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="],
924974
975 "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
976
925977 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
926978
927979 "joi": ["joi@18.0.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.0.0" } }, "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA=="],
......@@ -942,6 +994,10 @@
942994
943995 "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
944996
997 "lexical": ["lexical@0.41.0", "", {}, "sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA=="],
998
999 "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
1000
9451001 "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
9461002
9471003 "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
......@@ -1154,6 +1210,8 @@
11541210
11551211 "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
11561212
1213 "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
1214
11571215 "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
11581216
11591217 "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
......@@ -1172,6 +1230,8 @@
11721230
11731231 "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
11741232
1233 "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="],
1234
11751235 "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
11761236
11771237 "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
......@@ -1414,6 +1474,8 @@
14141474
14151475 "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
14161476
1477 "yjs": ["yjs@13.6.29", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ=="],
1478
14171479 "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="],
14181480
14191481 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],