| author | |
| committer | |
| log | bf7002209e6663d6c208c6dd0546277582077aea |
| tree | f0633b45fb5f815fc61389ffd8753ef3685f3b4a |
| parent | a3aa4bd189721a87271e9d7a1bed0cb605bc5eee |
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 @@ |
| 12 | 12 | }, |
| 13 | 13 | "dependencies": { |
| 14 | 14 | "@base-ui/react": "^1.2.0", |
| 15 | "@lexical/react": "^0.41.0", | |
| 15 | 16 | "@pierre/diffs": "^1.1.0-beta.16", |
| 16 | 17 | "@t3tools/contracts": "workspace:*", |
| 17 | 18 | "@tanstack/react-pacer": "^0.19.4", |
| ... | ... | @@ -22,6 +23,7 @@ |
| 22 | 23 | "@xterm/xterm": "^6.0.0", |
| 23 | 24 | "class-variance-authority": "^0.7.1", |
| 24 | 25 | "effect": "catalog:", |
| 26 | "lexical": "^0.41.0", | |
| 25 | 27 | "lucide-react": "^0.564.0", |
| 26 | 28 | "react": "^19.0.0", |
| 27 | 29 | "react-dom": "^19.0.0", |
apps/web/src/components/ChatView.tsx+138-93| ... | ... | @@ -37,6 +37,7 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer |
| 37 | 37 | import { isElectron } from "../env"; |
| 38 | 38 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 39 | 39 | import { |
| 40 | type ComposerTrigger, | |
| 40 | 41 | type ComposerTriggerKind, |
| 41 | 42 | detectComposerTrigger, |
| 42 | 43 | replaceTextRange, |
| ... | ... | @@ -127,6 +128,7 @@ import { |
| 127 | 128 | } from "../composerDraftStore"; |
| 128 | 129 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 129 | 130 | import { clamp } from "effect/Number"; |
| 131 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; | |
| 130 | 132 | |
| 131 | 133 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 132 | 134 | if (!duration) return formatTimestamp(createdAt); |
| ... | ... | @@ -458,10 +460,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 458 | 460 | const composerDraft = useComposerThreadDraft(threadId); |
| 459 | 461 | const prompt = composerDraft.prompt; |
| 460 | 462 | const composerImages = composerDraft.images; |
| 461 | const composerCursor = composerDraft.cursor; | |
| 462 | 463 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; |
| 463 | 464 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 464 | const setComposerDraftCursor = useComposerDraftStore((store) => store.setCursor); | |
| 465 | 465 | const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); |
| 466 | 466 | const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); |
| 467 | 467 | const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); |
| ... | ... | @@ -500,6 +500,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 500 | 500 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< |
| 501 | 501 | Record<string, string[]> |
| 502 | 502 | >({}); |
| 503 | const [composerCursor, setComposerCursor] = useState(() => prompt.length); | |
| 504 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => | |
| 505 | detectComposerTrigger(prompt, prompt.length), | |
| 506 | ); | |
| 503 | 507 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< |
| 504 | 508 | Record<string, string> |
| 505 | 509 | >(() => readLastInvokedScriptByProjectFromStorage()); |
| ... | ... | @@ -507,10 +511,15 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 507 | 511 | const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null); |
| 508 | 512 | const shouldAutoScrollRef = useRef(true); |
| 509 | 513 | const pendingAutoScrollFrameRef = useRef<number | null>(null); |
| 510 | const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| 514 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); | |
| 515 | const composerCommandInputRef = useRef<HTMLInputElement>(null); | |
| 511 | 516 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 512 | 517 | const composerFormHeightRef = useRef(0); |
| 513 | 518 | 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); | |
| 514 | 523 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 515 | 524 | const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({}); |
| 516 | 525 | const sendInFlightRef = useRef(false); |
| ... | ... | @@ -537,12 +546,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 537 | 546 | }, |
| 538 | 547 | [setComposerDraftPrompt, threadId], |
| 539 | 548 | ); |
| 540 | const setComposerCursor = useCallback( | |
| 541 | (nextCursor: number) => { | |
| 542 | setComposerDraftCursor(threadId, nextCursor); | |
| 543 | }, | |
| 544 | [setComposerDraftCursor, threadId], | |
| 545 | ); | |
| 546 | 549 | const addComposerImage = useCallback( |
| 547 | 550 | (image: ComposerImageAttachment) => { |
| 548 | 551 | addComposerDraftImage(threadId, image); |
| ... | ... | @@ -845,10 +848,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 845 | 848 | timelineEntries, |
| 846 | 849 | ]); |
| 847 | 850 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 848 | const composerTrigger = useMemo( | |
| 849 | () => detectComposerTrigger(prompt, composerCursor), | |
| 850 | [prompt, composerCursor], | |
| 851 | ); | |
| 852 | 851 | const composerTriggerKind = composerTrigger?.kind ?? null; |
| 853 | 852 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; |
| 854 | 853 | const isPathTrigger = composerTriggerKind === "path"; |
| ... | ... | @@ -927,6 +926,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 927 | 926 | null, |
| 928 | 927 | [composerHighlightedItemId, composerMenuItems], |
| 929 | 928 | ); |
| 929 | composerMenuOpenRef.current = composerMenuOpen; | |
| 930 | composerMenuItemsRef.current = composerMenuItems; | |
| 931 | activeComposerMenuItemRef.current = activeComposerMenuItem; | |
| 930 | 932 | const nonPersistedComposerImageIdSet = useMemo( |
| 931 | 933 | () => new Set(nonPersistedComposerImageIds), |
| 932 | 934 | [nonPersistedComposerImageIds], |
| ... | ... | @@ -1000,11 +1002,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1000 | 1002 | ); |
| 1001 | 1003 | |
| 1002 | 1004 | 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(); | |
| 1008 | 1006 | }, []); |
| 1009 | 1007 | const scheduleComposerFocus = useCallback(() => { |
| 1010 | 1008 | window.requestAnimationFrame(() => { |
| ... | ... | @@ -1434,8 +1432,25 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1434 | 1432 | |
| 1435 | 1433 | useEffect(() => { |
| 1436 | 1434 | promptRef.current = prompt; |
| 1435 | setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length)); | |
| 1437 | 1436 | }, [prompt]); |
| 1438 | 1437 | |
| 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 | ||
| 1439 | 1454 | useEffect(() => { |
| 1440 | 1455 | let cancelled = false; |
| 1441 | 1456 | void (async () => { |
| ... | ... | @@ -1557,14 +1572,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1557 | 1572 | ? (draftThread?.envMode ?? "local") |
| 1558 | 1573 | : "local"; |
| 1559 | 1574 | |
| 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 | ||
| 1568 | 1575 | useEffect(() => { |
| 1569 | 1576 | if (phase !== "running") return; |
| 1570 | 1577 | const timer = window.setInterval(() => { |
| ... | ... | @@ -1727,7 +1734,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1727 | 1734 | removeComposerImageFromDraft(imageId); |
| 1728 | 1735 | }; |
| 1729 | 1736 | |
| 1730 | const onComposerPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => { | |
| 1737 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { | |
| 1731 | 1738 | const files = Array.from(event.clipboardData.files); |
| 1732 | 1739 | if (files.length === 0) { |
| 1733 | 1740 | return; |
| ... | ... | @@ -1826,8 +1833,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1826 | 1833 | [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], |
| 1827 | 1834 | ); |
| 1828 | 1835 | |
| 1829 | const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => { | |
| 1830 | e.preventDefault(); | |
| 1836 | const onSend = async (e?: { preventDefault: () => void }) => { | |
| 1837 | e?.preventDefault(); | |
| 1831 | 1838 | const api = readNativeApi(); |
| 1832 | 1839 | if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; |
| 1833 | 1840 | const trimmed = prompt.trim(); |
| ... | ... | @@ -1894,6 +1901,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1894 | 1901 | promptRef.current = ""; |
| 1895 | 1902 | clearComposerDraftContent(threadIdForSend); |
| 1896 | 1903 | setComposerHighlightedItemId(null); |
| 1904 | setComposerCursor(0); | |
| 1905 | setComposerTrigger(null); | |
| 1897 | 1906 | |
| 1898 | 1907 | let createdServerThreadForLocalDraft = false; |
| 1899 | 1908 | let turnStartSucceeded = false; |
| ... | ... | @@ -2050,8 +2059,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2050 | 2059 | }); |
| 2051 | 2060 | promptRef.current = trimmed; |
| 2052 | 2061 | setPrompt(trimmed); |
| 2053 | addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); | |
| 2054 | 2062 | setComposerCursor(trimmed.length); |
| 2063 | addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); | |
| 2064 | setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); | |
| 2055 | 2065 | } |
| 2056 | 2066 | setThreadError( |
| 2057 | 2067 | threadIdForSend, |
| ... | ... | @@ -2144,40 +2154,96 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2144 | 2154 | ); |
| 2145 | 2155 | |
| 2146 | 2156 | 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 | } | |
| 2148 | 2172 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); |
| 2149 | 2173 | promptRef.current = next.text; |
| 2150 | 2174 | setPrompt(next.text); |
| 2175 | setComposerCursor(next.cursor); | |
| 2176 | setComposerTrigger(detectComposerTrigger(next.text, next.cursor)); | |
| 2151 | 2177 | 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); | |
| 2157 | 2179 | }); |
| 2180 | return true; | |
| 2158 | 2181 | }, |
| 2159 | [setComposerCursor, setPrompt], | |
| 2182 | [setPrompt], | |
| 2160 | 2183 | ); |
| 2161 | 2184 | |
| 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 | ||
| 2162 | 2204 | const onSelectComposerItem = useCallback( |
| 2163 | 2205 | (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); | |
| 2165 | 2214 | if (item.type === "path") { |
| 2166 | applyPromptReplacement( | |
| 2167 | composerTrigger.rangeStart, | |
| 2168 | composerTrigger.rangeEnd, | |
| 2215 | const applied = applyPromptReplacement( | |
| 2216 | trigger.rangeStart, | |
| 2217 | trigger.rangeEnd, | |
| 2169 | 2218 | `@${item.path} `, |
| 2219 | { expectedText: expectedToken }, | |
| 2170 | 2220 | ); |
| 2221 | if (applied) { | |
| 2222 | setComposerHighlightedItemId(null); | |
| 2223 | } | |
| 2171 | 2224 | return; |
| 2172 | 2225 | } |
| 2173 | 2226 | 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 | } | |
| 2175 | 2236 | return; |
| 2176 | 2237 | } |
| 2177 | 2238 | 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 | } | |
| 2179 | 2245 | }, |
| 2180 | [applyPromptReplacement, composerTrigger, onModelSelect], | |
| 2246 | [applyPromptReplacement, onModelSelect, resolveActiveComposerTrigger], | |
| 2181 | 2247 | ); |
| 2182 | 2248 | const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { |
| 2183 | 2249 | setComposerHighlightedItemId(itemId); |
| ... | ... | @@ -2206,41 +2272,41 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2206 | 2272 | workspaceEntriesQuery.isLoading || |
| 2207 | 2273 | workspaceEntriesQuery.isFetching); |
| 2208 | 2274 | |
| 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; | |
| 2217 | 2285 | |
| 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) { | |
| 2222 | 2289 | nudgeComposerMenuHighlight("ArrowDown"); |
| 2223 | return; | |
| 2290 | return true; | |
| 2224 | 2291 | } |
| 2225 | if (e.key === "ArrowUp") { | |
| 2226 | e.preventDefault(); | |
| 2292 | if (key === "ArrowUp" && currentItems.length > 0) { | |
| 2227 | 2293 | nudgeComposerMenuHighlight("ArrowUp"); |
| 2228 | return; | |
| 2294 | return true; | |
| 2229 | 2295 | } |
| 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]; | |
| 2232 | 2298 | if (selectedItem) { |
| 2233 | e.preventDefault(); | |
| 2234 | 2299 | onSelectComposerItem(selectedItem); |
| 2235 | return; | |
| 2236 | 2300 | } |
| 2301 | return true; | |
| 2237 | 2302 | } |
| 2238 | 2303 | } |
| 2239 | 2304 | |
| 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; | |
| 2243 | 2308 | } |
| 2309 | return false; | |
| 2244 | 2310 | }; |
| 2245 | 2311 | const onToggleWorkGroup = useCallback((groupId: string) => { |
| 2246 | 2312 | setExpandedWorkGroups((existing) => ({ |
| ... | ... | @@ -2464,33 +2530,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2464 | 2530 | ))} |
| 2465 | 2531 | </div> |
| 2466 | 2532 | )} |
| 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} | |
| 2471 | 2535 | 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} | |
| 2494 | 2539 | onPaste={onComposerPaste} |
| 2495 | 2540 | placeholder={ |
| 2496 | 2541 | phase === "disconnected" |
apps/web/src/components/ComposerPromptEditor.tsx created+664| ... | ... | @@ -0,0 +1,664 @@ |
| 1 | import { | |
| 2 | LexicalComposer, | |
| 3 | type InitialConfigType, | |
| 4 | } from "@lexical/react/LexicalComposer"; | |
| 5 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; | |
| 6 | import { ContentEditable } from "@lexical/react/LexicalContentEditable"; | |
| 7 | import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; | |
| 8 | import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; | |
| 9 | import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; | |
| 10 | import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; | |
| 11 | import { | |
| 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"; | |
| 39 | import { | |
| 40 | forwardRef, | |
| 41 | useCallback, | |
| 42 | useEffect, | |
| 43 | useImperativeHandle, | |
| 44 | useLayoutEffect, | |
| 45 | useMemo, | |
| 46 | useRef, | |
| 47 | type ClipboardEventHandler, | |
| 48 | type Ref, | |
| 49 | } from "react"; | |
| 50 | ||
| 51 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; | |
| 52 | import { cn } from "~/lib/utils"; | |
| 53 | import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; | |
| 54 | ||
| 55 | const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; | |
| 56 | ||
| 57 | type SerializedComposerMentionNode = Spread< | |
| 58 | { | |
| 59 | path: string; | |
| 60 | type: "composer-mention"; | |
| 61 | version: 1; | |
| 62 | }, | |
| 63 | SerializedTextNode | |
| 64 | >; | |
| 65 | ||
| 66 | class 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 | ||
| 132 | function $createComposerMentionNode(path: string): ComposerMentionNode { | |
| 133 | return $applyNodeReplacement(new ComposerMentionNode(path)); | |
| 134 | } | |
| 135 | ||
| 136 | function 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 | ||
| 147 | function 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 | ||
| 164 | function 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 | ||
| 169 | function 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 | ||
| 182 | function 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 | ||
| 223 | function 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 | ||
| 282 | function $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 | ||
| 299 | function $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 | ||
| 309 | function $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 | ||
| 322 | function $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 | ||
| 338 | export interface ComposerPromptEditorHandle { | |
| 339 | focus: () => void; | |
| 340 | focusAt: (cursor: number) => void; | |
| 341 | focusAtEnd: () => void; | |
| 342 | readSnapshot: () => { value: string; cursor: number }; | |
| 343 | } | |
| 344 | ||
| 345 | interface 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 | ||
| 359 | interface ComposerPromptEditorInnerProps extends ComposerPromptEditorProps { | |
| 360 | editorRef: Ref<ComposerPromptEditorHandle>; | |
| 361 | } | |
| 362 | ||
| 363 | function 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 | ||
| 419 | function 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 | ||
| 480 | function 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 | ||
| 627 | export 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | export type ComposerPromptSegment = | |
| 2 | | { | |
| 3 | type: "text"; | |
| 4 | text: string; | |
| 5 | } | |
| 6 | | { | |
| 7 | type: "mention"; | |
| 8 | path: string; | |
| 9 | }; | |
| 10 | ||
| 11 | const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; | |
| 12 | ||
| 13 | function 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 | ||
| 23 | export 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 |
| 28 | 28 | |
| 29 | 29 | interface PersistedComposerThreadDraftState { |
| 30 | 30 | prompt: string; |
| 31 | cursor: number; | |
| 32 | 31 | attachments: PersistedComposerImageAttachment[]; |
| 33 | 32 | model?: string | null; |
| 34 | 33 | effort?: ReasoningEffort | null; |
| ... | ... | @@ -50,7 +49,6 @@ interface PersistedComposerDraftStoreState { |
| 50 | 49 | |
| 51 | 50 | interface ComposerThreadDraftState { |
| 52 | 51 | prompt: string; |
| 53 | cursor: number; | |
| 54 | 52 | images: ComposerImageAttachment[]; |
| 55 | 53 | nonPersistedImageIds: string[]; |
| 56 | 54 | persistedAttachments: PersistedComposerImageAttachment[]; |
| ... | ... | @@ -100,7 +98,6 @@ interface ComposerDraftStoreState { |
| 100 | 98 | clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; |
| 101 | 99 | clearDraftThread: (threadId: ThreadId) => void; |
| 102 | 100 | setPrompt: (threadId: ThreadId, prompt: string) => void; |
| 103 | setCursor: (threadId: ThreadId, cursor: number) => void; | |
| 104 | 101 | setModel: (threadId: ThreadId, model: string | null | undefined) => void; |
| 105 | 102 | setEffort: (threadId: ThreadId, effort: ReasoningEffort | null | undefined) => void; |
| 106 | 103 | addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; |
| ... | ... | @@ -129,7 +126,6 @@ Object.freeze(EMPTY_IDS); |
| 129 | 126 | Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); |
| 130 | 127 | const EMPTY_THREAD_DRAFT = Object.freeze({ |
| 131 | 128 | prompt: "", |
| 132 | cursor: 0, | |
| 133 | 129 | images: EMPTY_IMAGES, |
| 134 | 130 | nonPersistedImageIds: EMPTY_IDS, |
| 135 | 131 | persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, |
| ... | ... | @@ -142,7 +138,6 @@ const REASONING_EFFORT_VALUES = new Set<ReasoningEffort>(REASONING_OPTIONS); |
| 142 | 138 | function createEmptyThreadDraft(): ComposerThreadDraftState { |
| 143 | 139 | return { |
| 144 | 140 | prompt: "", |
| 145 | cursor: 0, | |
| 146 | 141 | images: [], |
| 147 | 142 | nonPersistedImageIds: [], |
| 148 | 143 | persistedAttachments: [], |
| ... | ... | @@ -304,10 +299,6 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 304 | 299 | } |
| 305 | 300 | const draftCandidate = draftValue as Record<string, unknown>; |
| 306 | 301 | 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; | |
| 311 | 302 | const attachments = Array.isArray(draftCandidate.attachments) |
| 312 | 303 | ? draftCandidate.attachments.flatMap((entry) => { |
| 313 | 304 | const normalized = normalizePersistedAttachment(entry); |
| ... | ... | @@ -327,7 +318,6 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 327 | 318 | } |
| 328 | 319 | nextDraftsByThreadId[threadId as ThreadId] = { |
| 329 | 320 | prompt, |
| 330 | cursor, | |
| 331 | 321 | attachments, |
| 332 | 322 | ...(model ? { model } : {}), |
| 333 | 323 | ...(effort ? { effort } : {}), |
| ... | ... | @@ -429,7 +419,6 @@ function toHydratedThreadDraft( |
| 429 | 419 | ): ComposerThreadDraftState { |
| 430 | 420 | return { |
| 431 | 421 | prompt: persistedDraft.prompt, |
| 432 | cursor: persistedDraft.cursor, | |
| 433 | 422 | images: hydrateImagesFromPersisted(persistedDraft.attachments), |
| 434 | 423 | nonPersistedImageIds: [], |
| 435 | 424 | persistedAttachments: persistedDraft.attachments, |
| ... | ... | @@ -672,36 +661,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 672 | 661 | } |
| 673 | 662 | set((state) => { |
| 674 | 663 | const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); |
| 675 | const cursor = Math.min(existing.cursor, prompt.length); | |
| 676 | 664 | const nextDraft: ComposerThreadDraftState = { |
| 677 | 665 | ...existing, |
| 678 | 666 | 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, | |
| 705 | 667 | }; |
| 706 | 668 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; |
| 707 | 669 | if (shouldRemoveDraft(nextDraft)) { |
| ... | ... | @@ -939,7 +901,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 939 | 901 | const nextDraft: ComposerThreadDraftState = { |
| 940 | 902 | ...current, |
| 941 | 903 | prompt: "", |
| 942 | cursor: 0, | |
| 943 | 904 | images: [], |
| 944 | 905 | nonPersistedImageIds: [], |
| 945 | 906 | persistedAttachments: [], |
| ... | ... | @@ -1009,7 +970,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1009 | 970 | } |
| 1010 | 971 | const persistedDraft: PersistedComposerThreadDraftState = { |
| 1011 | 972 | prompt: draft.prompt, |
| 1012 | cursor: Math.min(Math.max(0, draft.cursor), draft.prompt.length), | |
| 1013 | 973 | attachments: draft.persistedAttachments, |
| 1014 | 974 | }; |
| 1015 | 975 | if (draft.model) { |
bun.lock+62| ... | ... | @@ -63,6 +63,7 @@ |
| 63 | 63 | "version": "0.0.0-alpha.4", |
| 64 | 64 | "dependencies": { |
| 65 | 65 | "@base-ui/react": "^1.2.0", |
| 66 | "@lexical/react": "^0.41.0", | |
| 66 | 67 | "@pierre/diffs": "^1.1.0-beta.16", |
| 67 | 68 | "@t3tools/contracts": "workspace:*", |
| 68 | 69 | "@tanstack/react-pacer": "^0.19.4", |
| ... | ... | @@ -73,6 +74,7 @@ |
| 73 | 74 | "@xterm/xterm": "^6.0.0", |
| 74 | 75 | "class-variance-authority": "^0.7.1", |
| 75 | 76 | "effect": "catalog:", |
| 77 | "lexical": "^0.41.0", | |
| 76 | 78 | "lucide-react": "^0.564.0", |
| 77 | 79 | "react": "^19.0.0", |
| 78 | 80 | "react-dom": "^19.0.0", |
| ... | ... | @@ -238,6 +240,8 @@ |
| 238 | 240 | |
| 239 | 241 | "@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=="], |
| 240 | 242 | |
| 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 | ||
| 241 | 245 | "@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=="], |
| 242 | 246 | |
| 243 | 247 | "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], |
| ... | ... | @@ -276,6 +280,50 @@ |
| 276 | 280 | |
| 277 | 281 | "@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=="], |
| 278 | 282 | |
| 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 | ||
| 279 | 327 | "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], |
| 280 | 328 | |
| 281 | 329 | "@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 @@ |
| 380 | 428 | |
| 381 | 429 | "@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=="], |
| 382 | 430 | |
| 431 | "@preact/signals-core": ["@preact/signals-core@1.13.0", "", {}, "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg=="], | |
| 432 | ||
| 383 | 433 | "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], |
| 384 | 434 | |
| 385 | 435 | "@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 @@ |
| 922 | 972 | |
| 923 | 973 | "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], |
| 924 | 974 | |
| 975 | "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], | |
| 976 | ||
| 925 | 977 | "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], |
| 926 | 978 | |
| 927 | 979 | "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 @@ |
| 942 | 994 | |
| 943 | 995 | "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], |
| 944 | 996 | |
| 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 | ||
| 945 | 1001 | "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=="], |
| 946 | 1002 | |
| 947 | 1003 | "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], |
| ... | ... | @@ -1154,6 +1210,8 @@ |
| 1154 | 1210 | |
| 1155 | 1211 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], |
| 1156 | 1212 | |
| 1213 | "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], | |
| 1214 | ||
| 1157 | 1215 | "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], |
| 1158 | 1216 | |
| 1159 | 1217 | "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], |
| ... | ... | @@ -1172,6 +1230,8 @@ |
| 1172 | 1230 | |
| 1173 | 1231 | "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], |
| 1174 | 1232 | |
| 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 | ||
| 1175 | 1235 | "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=="], |
| 1176 | 1236 | |
| 1177 | 1237 | "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], |
| ... | ... | @@ -1414,6 +1474,8 @@ |
| 1414 | 1474 | |
| 1415 | 1475 | "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], |
| 1416 | 1476 | |
| 1477 | "yjs": ["yjs@13.6.29", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ=="], | |
| 1478 | ||
| 1417 | 1479 | "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], |
| 1418 | 1480 | |
| 1419 | 1481 | "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], |