[aidan] feat: collapsed long text to avoid long input lag

This commit is contained in:
Aidan
2026-06-12 21:58:56 -07:00
committed by GitHub
parent eadd6f71a2
commit d1e3c37b27
5 changed files with 230 additions and 6 deletions
@@ -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>
);
};