mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-21 17:17:41 +02:00
[aidan] feat: collapsed long text to avoid long input lag
This commit is contained in:
@@ -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<string, string>();
|
||||
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<strin
|
||||
parts.push(`{{skill:${skills[sid].name}}}`);
|
||||
return;
|
||||
}
|
||||
const pid = el.getAttribute(PASTE_CARD_ATTR);
|
||||
if (pid) {
|
||||
const content = _pasteStore.get(pid);
|
||||
if (content) {
|
||||
hasOutput = true;
|
||||
parts.push(content);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (el.tagName === 'BR') { parts.push('\n'); return; }
|
||||
if (el.tagName === 'DIV' || el.tagName === 'P') {
|
||||
if (hasOutput) parts.push('\n');
|
||||
@@ -163,7 +259,9 @@ export function detectEditorTrigger(): TriggerState | null {
|
||||
|
||||
let triggerIdx = -1;
|
||||
let triggerChar: '/' | '@' | null = null;
|
||||
for (let i = before.length - 1; i >= 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 === '@') {
|
||||
|
||||
@@ -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<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
|
||||
const [hasContent, setHasContent] = useState(() => !!loadDraft(ownerId));
|
||||
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
|
||||
const [previewPasteId, setPreviewPasteId] = useState<string | null>(null);
|
||||
const attachedSkillsRef = useRef(attachedSkills);
|
||||
attachedSkillsRef.current = attachedSkills;
|
||||
|
||||
@@ -275,6 +277,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
const hasAttachments = images.length > 0 || contextPaths.length > 0 || forcedTools.length > 0 || selectedElements.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PastePreviewDialog pasteId={previewPasteId} onClose={() => setPreviewPasteId(null)} />
|
||||
<ChatInputView
|
||||
c={c}
|
||||
containerRef={containerRef}
|
||||
@@ -365,6 +370,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
summarizeError={summarizeError}
|
||||
setSummarizeError={setSummarizeError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string>();
|
||||
@@ -40,6 +41,11 @@ export function useDraftLoad(editorRef: RefObject<HTMLDivElement>, 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);
|
||||
|
||||
@@ -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<TriggerState>(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();
|
||||
|
||||
@@ -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<Props> = ({ pasteId, onClose }) => {
|
||||
const c = useClaudeTokens();
|
||||
const open = !!pasteId;
|
||||
const content = pasteId ? (getPasteContent(pasteId) ?? '') : '';
|
||||
const chars = content.length;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
PaperProps={{ sx: { bgcolor: c.bg.elevated, borderRadius: 3, p: 0, minWidth: 520, maxWidth: 760, width: '70vw' } }}
|
||||
>
|
||||
<Box sx={{ p: 2, borderBottom: `1px solid ${c.border.subtle}` }}>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '1rem', fontWeight: 600 }}>
|
||||
Pasted text
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.75rem', mt: 0.25 }}>
|
||||
{chars.toLocaleString()} characters
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
fontFamily: c.font.mono,
|
||||
fontSize: '0.78rem',
|
||||
color: c.text.primary,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
bgcolor: c.bg.surface,
|
||||
}}
|
||||
>
|
||||
{content || (
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.85rem', fontStyle: 'italic' }}>
|
||||
This pasted text is no longer available. Re-paste to restore it.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user