diff --git a/frontend/src/app/components/editor/richEditorUtils.ts b/frontend/src/app/components/editor/richEditorUtils.ts index cae8450c..9de93af0 100644 --- a/frontend/src/app/components/editor/richEditorUtils.ts +++ b/frontend/src/app/components/editor/richEditorUtils.ts @@ -1,6 +1,30 @@ export const SKILL_PILL_ATTR = 'data-skill-id'; export const SKILL_COLOR = '#7B61BD'; +// Paste cards hold large pasted text outside Chromium's contentEditable text-node tree, so the editor stays fast. +export const PASTE_CARD_ATTR = 'data-paste-id'; +export const PASTE_CARD_COLOR = '#5A8FBF'; +export const LARGE_PASTE_CHARS = 500; +const _pasteStore = new Map(); +let _pasteCounter = 0; + +export function getPasteContent(id: string): string | undefined { + return _pasteStore.get(id); +} + +export function setPasteContent(id: string, text: string): void { + _pasteStore.set(id, text); +} + +export function deletePasteContent(id: string): void { + _pasteStore.delete(id); +} + +export function createPasteId(): string { + _pasteCounter += 1; + return `paste_${Date.now().toString(36)}_${_pasteCounter}`; +} + export interface AttachedSkill { id: string; name: string; @@ -63,6 +87,69 @@ export function createSkillPillElement( return pill; } +function formatPasteLabel(charCount: number): string { + return `Pasted text (${charCount.toLocaleString()} chars)`; +} + +export function createPasteCardElement( + pasteId: string, + charCount: number, + onExpand: (id: string) => void, + onRemove: (id: string) => void, + monoFont: string, + errorColor: string, +): HTMLSpanElement { + const card = document.createElement('span'); + card.setAttribute(PASTE_CARD_ATTR, pasteId); + card.contentEditable = 'false'; + Object.assign(card.style, { + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + padding: '1px 4px 1px 8px', + margin: '0 1px', + borderRadius: '6px', + background: `${PASTE_CARD_COLOR}1A`, + color: PASTE_CARD_COLOR, + fontSize: '0.72rem', + fontFamily: monoFont, + lineHeight: '1.8', + verticalAlign: 'baseline', + userSelect: 'none', + whiteSpace: 'nowrap' as const, + cursor: 'pointer', + }); + + const label = document.createElement('span'); + label.textContent = formatPasteLabel(charCount); + Object.assign(label.style, { maxWidth: '240px', overflow: 'hidden', textOverflow: 'ellipsis' }); + label.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); onExpand(pasteId); }); + + const closeBtn = document.createElement('span'); + closeBtn.textContent = '×'; + Object.assign(closeBtn.style, { + cursor: 'pointer', + fontSize: '13px', + lineHeight: '1', + opacity: '0.6', + marginLeft: '2px', + fontWeight: '700', + borderRadius: '50%', + width: '14px', + height: '14px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + }); + closeBtn.addEventListener('mouseover', () => { closeBtn.style.opacity = '1'; closeBtn.style.color = errorColor; }); + closeBtn.addEventListener('mouseout', () => { closeBtn.style.opacity = '0.6'; closeBtn.style.color = 'inherit'; }); + closeBtn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); onRemove(pasteId); }); + + card.appendChild(label); + card.appendChild(closeBtn); + return card; +} + const SKILL_MARKER_RE = /\{\{skill:(.+?)\}\}/g; export function deserializeToEditor( @@ -119,6 +206,15 @@ export function serializeEditorContent(editor: HTMLElement, skills: Record= 0; i--) { + const MAX_TRIGGER_SCAN = 256; + const scanFloor = Math.max(0, before.length - MAX_TRIGGER_SCAN); + for (let i = before.length - 1; i >= scanFloor; i--) { const ch = before[i]; if (ch === ' ' || ch === '\n') break; if (ch === '@') { diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index 428bd878..2a50beee 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -16,6 +16,7 @@ import { useContextFiles } from './ChatInput/hooks/useContextFiles'; import { useModelPicker } from './ChatInput/hooks/useModelPicker'; import { useEditorHandlers } from './ChatInput/hooks/useEditorHandlers'; import { ChatInputView } from './ChatInput/view/ChatInputView'; +import { PastePreviewDialog } from './ChatInput/view/PastePreviewDialog'; import { ICON_MAP, FALLBACK_MODE_BASE } from './ChatInput/modeConfig'; import { AttachedImage, ForcedToolGroup, ChatInputHandle } from './ChatInput/types'; @@ -93,6 +94,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, const [hasContent, setHasContent] = useState(() => !!loadDraft(ownerId)); const [attachedSkills, setAttachedSkills] = useState>({}); + const [previewPasteId, setPreviewPasteId] = useState(null); const attachedSkillsRef = useRef(attachedSkills); attachedSkillsRef.current = attachedSkills; @@ -275,6 +277,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, editorRef, generalFileInputRef, ownerId, sessionId, autoRunMode, c, skills, elementSelection, setHasContent, setAttachedSkills, setForcedTools, onModeChange, addImageFiles, uploadAndAttachFiles, handleSend, + onPasteExpand: setPreviewPasteId, }); const currentMode = modesMap[mode]; @@ -287,6 +290,8 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, const hasAttachments = images.length > 0 || contextPaths.length > 0 || forcedTools.length > 0 || selectedElements.length > 0; return ( + <> + setPreviewPasteId(null)} /> (({ onSend, disabled, mode, summarizeError={summarizeError} setSummarizeError={setSummarizeError} /> + ); }); diff --git a/frontend/src/app/pages/AgentChat/ChatInput/hooks/draftStore.ts b/frontend/src/app/pages/AgentChat/ChatInput/hooks/draftStore.ts index 15dbe594..1f8ff7b9 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/hooks/draftStore.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/hooks/draftStore.ts @@ -1,4 +1,5 @@ import { useEffect, RefObject } from 'react'; +import { PASTE_CARD_ATTR, getPasteContent } from '@/app/components/editor/richEditorUtils'; // Module-level draft store keyed by sessionId; survives unmount/remount and preserves skill pills via innerHTML. const _draftStore = new Map(); @@ -40,6 +41,11 @@ export function useDraftLoad(editorRef: RefObject, ownerId: stri } if (!editor.textContent?.trim()) { editor.innerHTML = saved; + const staleCards = editor.querySelectorAll(`[${PASTE_CARD_ATTR}]`); + staleCards.forEach((el) => { + const pid = el.getAttribute(PASTE_CARD_ATTR); + if (!pid || !getPasteContent(pid)) el.remove(); + }); const range = document.createRange(); range.selectNodeContents(editor); range.collapse(false); diff --git a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts index d1544823..1df6af7e 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts @@ -3,8 +3,14 @@ import { CommandPickerItem } from '@/app/components/editor/CommandPicker'; import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; import { SKILL_PILL_ATTR, + PASTE_CARD_ATTR, + LARGE_PASTE_CHARS, AttachedSkill, createSkillPillElement, + createPasteCardElement, + createPasteId, + setPasteContent, + deletePasteContent, detectEditorTrigger, TriggerState, EMPTY_TRIGGER, @@ -54,13 +60,14 @@ interface Params { addImageFiles: (files: FileList | File[]) => void; uploadAndAttachFiles: (files: File[]) => void; handleSend: () => void; + onPasteExpand: (pasteId: string) => void; } export function useEditorHandlers(p: Params) { const { editorRef, generalFileInputRef, ownerId, sessionId, autoRunMode, c, skills, elementSelection, setHasContent, setAttachedSkills, setForcedTools, onModeChange, - addImageFiles, uploadAndAttachFiles, handleSend, + addImageFiles, uploadAndAttachFiles, handleSend, onPasteExpand, } = p; const dispatch = useAppDispatch(); const [picker, setPicker] = useState(EMPTY_TRIGGER); @@ -95,6 +102,16 @@ export function useEditorHandlers(p: Params) { }); }, []); + const removePasteCard = useCallback((pasteId: string) => { + const editor = editorRef.current; + if (!editor) return; + const card = editor.querySelector(`[${PASTE_CARD_ATTR}="${pasteId}"]`); + if (card) card.remove(); + deletePasteContent(pasteId); + updateHasContent(); + editor.focus(); + }, [updateHasContent]); + const removeSkillPill = useCallback((skillId: string) => { const editor = editorRef.current; if (!editor) return; @@ -251,11 +268,54 @@ export function useEditorHandlers(p: Params) { } e.preventDefault(); const plain = e.clipboardData.getData('text/plain'); - if (plain) { - justPastedRef.current = true; - document.execCommand('insertText', false, plain); + if (!plain) return; + + // Card path applies only to contentEditable; the Windows textarea fallback handles big text natively without lag and can't host child nodes. + if (plain.length > LARGE_PASTE_CHARS && !isTextareaEl(editorRef.current)) { + const pasteId = createPasteId(); + setPasteContent(pasteId, plain); + const card = createPasteCardElement( + pasteId, + plain.length, + onPasteExpand, + removePasteCard, + c.font.mono, + c.status.error, + ); + + const editor = editorRef.current; + if (!editor) return; + editor.focus(); + const sel = window.getSelection(); + let inserted = false; + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0); + if (editor.contains(range.startContainer)) { + range.deleteContents(); + range.insertNode(card); + const spacer = document.createTextNode('​'); + card.after(spacer); + const newRange = document.createRange(); + newRange.setStartAfter(spacer); + newRange.collapse(true); + sel.removeAllRanges(); + sel.addRange(newRange); + inserted = true; + } + } + if (!inserted) { + editor.appendChild(card); + const spacer = document.createTextNode('​'); + editor.appendChild(spacer); + } + setHasContent(true); + scheduleDraftSave(ownerId, () => readEditorHTML(editor)); + return; } - }, [addImageFiles, elementSelection, ownerId]); + + justPastedRef.current = true; + document.execCommand('insertText', false, plain); + }, [addImageFiles, elementSelection, ownerId, onPasteExpand, removePasteCard, c.font.mono, c.status.error, setHasContent]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/PastePreviewDialog.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/PastePreviewDialog.tsx new file mode 100644 index 00000000..f884ad8d --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/PastePreviewDialog.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import Dialog from '@mui/material/Dialog'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { getPasteContent } from '@/app/components/editor/richEditorUtils'; + +interface Props { + pasteId: string | null; + onClose: () => void; +} + +export const PastePreviewDialog: React.FC = ({ pasteId, onClose }) => { + const c = useClaudeTokens(); + const open = !!pasteId; + const content = pasteId ? (getPasteContent(pasteId) ?? '') : ''; + const chars = content.length; + + return ( + + + + Pasted text + + + {chars.toLocaleString()} characters + + + + {content || ( + + This pasted text is no longer available. Re-paste to restore it. + + )} + + + ); +};