mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-05 01:07:42 +02:00
[aidan] ux/large-text-paste: make pasted-text cards editable with autosave
This commit is contained in:
@@ -91,6 +91,11 @@ function formatPasteLabel(charCount: number): string {
|
||||
return `Pasted text (${charCount.toLocaleString()} chars)`;
|
||||
}
|
||||
|
||||
export function updatePasteCardLabel(card: HTMLElement, charCount: number): void {
|
||||
const label = card.firstElementChild as HTMLElement | null;
|
||||
if (label) label.textContent = formatPasteLabel(charCount);
|
||||
}
|
||||
|
||||
export function createPasteCardElement(
|
||||
pasteId: string,
|
||||
charCount: number,
|
||||
@@ -122,7 +127,13 @@ export function createPasteCardElement(
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = formatPasteLabel(charCount);
|
||||
Object.assign(label.style, { maxWidth: '240px', overflow: 'hidden', textOverflow: 'ellipsis' });
|
||||
Object.assign(label.style, {
|
||||
maxWidth: '240px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
opacity: '0.85',
|
||||
transition: 'transform 0.15s ease, opacity 0.15s ease',
|
||||
});
|
||||
label.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); onExpand(pasteId); });
|
||||
|
||||
const closeBtn = document.createElement('span');
|
||||
@@ -145,6 +156,9 @@ export function createPasteCardElement(
|
||||
closeBtn.addEventListener('mouseout', () => { closeBtn.style.opacity = '0.6'; closeBtn.style.color = 'inherit'; });
|
||||
closeBtn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); onRemove(pasteId); });
|
||||
|
||||
card.addEventListener('mouseenter', () => { label.style.transform = 'translateX(2px)'; label.style.opacity = '1'; });
|
||||
card.addEventListener('mouseleave', () => { label.style.transform = 'translateX(0)'; label.style.opacity = '0.85'; });
|
||||
|
||||
card.appendChild(label);
|
||||
card.appendChild(closeBtn);
|
||||
return card;
|
||||
|
||||
@@ -107,8 +107,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
};
|
||||
}, [prefillPrompt]);
|
||||
|
||||
useDraftLoad(editorRef, ownerId);
|
||||
|
||||
const [hasContent, setHasContent] = useState(() => !!loadDraft(ownerId));
|
||||
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
|
||||
const [previewPasteId, setPreviewPasteId] = useState<string | null>(null);
|
||||
@@ -292,6 +290,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
isDragOver,
|
||||
handleInput, handleEditorClick, handlePickerSelect, handleKeyDown, handlePaste,
|
||||
handleDragOver, handleDragLeave, handleDrop,
|
||||
removePasteCard, savePasteCard,
|
||||
} = useEditorHandlers({
|
||||
editorRef, generalFileInputRef, ownerId, sessionId, autoRunMode, c, skills,
|
||||
elementSelection, setHasContent, setAttachedSkills, setForcedTools, onModeChange,
|
||||
@@ -299,6 +298,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
onPasteExpand: setPreviewPasteId,
|
||||
});
|
||||
|
||||
useDraftLoad(editorRef, ownerId, setPreviewPasteId, removePasteCard, c.font.mono, c.status.error);
|
||||
|
||||
const currentMode = modesMap[mode];
|
||||
const FALLBACK_MODE = { ...FALLBACK_MODE_BASE, color: c.accent.primary };
|
||||
const modeConf = currentMode
|
||||
@@ -310,7 +311,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
|
||||
return (
|
||||
<>
|
||||
<PastePreviewDialog pasteId={previewPasteId} onClose={() => setPreviewPasteId(null)} />
|
||||
<PastePreviewDialog pasteId={previewPasteId} onClose={() => setPreviewPasteId(null)} onSave={savePasteCard} />
|
||||
<ChatInputView
|
||||
c={c}
|
||||
containerRef={containerRef}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, RefObject } from 'react';
|
||||
import { PASTE_CARD_ATTR, getPasteContent } from '@/app/components/editor/richEditorUtils';
|
||||
import { PASTE_CARD_ATTR, getPasteContent, createPasteCardElement } 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>();
|
||||
@@ -26,7 +26,14 @@ export function deleteDraft(ownerId: string) {
|
||||
_draftStore.delete(ownerId);
|
||||
}
|
||||
|
||||
export function useDraftLoad(editorRef: RefObject<HTMLDivElement>, ownerId: string) {
|
||||
export function useDraftLoad(
|
||||
editorRef: RefObject<HTMLDivElement>,
|
||||
ownerId: string,
|
||||
onPasteExpand: (id: string) => void,
|
||||
onPasteRemove: (id: string) => void,
|
||||
monoFont: string,
|
||||
errorColor: string,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const saved = _draftStore.get(ownerId);
|
||||
const editor = editorRef.current;
|
||||
@@ -41,10 +48,17 @@ export function useDraftLoad(editorRef: RefObject<HTMLDivElement>, ownerId: stri
|
||||
}
|
||||
if (!editor.textContent?.trim()) {
|
||||
editor.innerHTML = saved;
|
||||
// innerHTML restore drops JS listeners, so stale paste cards are rebuilt fresh below instead of just kept.
|
||||
const staleCards = editor.querySelectorAll(`[${PASTE_CARD_ATTR}]`);
|
||||
staleCards.forEach((el) => {
|
||||
const pid = el.getAttribute(PASTE_CARD_ATTR);
|
||||
if (!pid || !getPasteContent(pid)) el.remove();
|
||||
const content = pid ? getPasteContent(pid) : undefined;
|
||||
if (!pid || content === undefined) {
|
||||
el.remove();
|
||||
return;
|
||||
}
|
||||
const fresh = createPasteCardElement(pid, content.length, onPasteExpand, onPasteRemove, monoFont, errorColor);
|
||||
el.replaceWith(fresh);
|
||||
});
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createPasteId,
|
||||
setPasteContent,
|
||||
deletePasteContent,
|
||||
updatePasteCardLabel,
|
||||
detectEditorTrigger,
|
||||
TriggerState,
|
||||
EMPTY_TRIGGER,
|
||||
@@ -112,6 +113,15 @@ export function useEditorHandlers(p: Params) {
|
||||
editor.focus();
|
||||
}, [updateHasContent]);
|
||||
|
||||
const savePasteCard = useCallback((pasteId: string, text: string) => {
|
||||
setPasteContent(pasteId, text);
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const card = editor.querySelector(`[${PASTE_CARD_ATTR}="${pasteId}"]`) as HTMLElement | null;
|
||||
if (card) updatePasteCardLabel(card, text.length);
|
||||
scheduleDraftSave(ownerId, () => readEditorHTML(editor));
|
||||
}, [ownerId]);
|
||||
|
||||
const removeSkillPill = useCallback((skillId: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
@@ -349,5 +359,6 @@ export function useEditorHandlers(p: Params) {
|
||||
updateHasContent,
|
||||
handleInput, handleEditorClick, handlePickerSelect, handleKeyDown, handlePaste,
|
||||
handleDragOver, handleDragLeave, handleDrop,
|
||||
removePasteCard, savePasteCard,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { getPasteContent } from '@/app/components/editor/richEditorUtils';
|
||||
|
||||
interface Props {
|
||||
pasteId: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (pasteId: string, text: string) => void;
|
||||
}
|
||||
|
||||
export const PastePreviewDialog: React.FC<Props> = ({ pasteId, onClose }) => {
|
||||
export const PastePreviewDialog: React.FC<Props> = ({ pasteId, onClose, onSave }) => {
|
||||
const c = useClaudeTokens();
|
||||
const open = !!pasteId;
|
||||
const content = pasteId ? (getPasteContent(pasteId) ?? '') : '';
|
||||
const chars = content.length;
|
||||
const original = pasteId ? getPasteContent(pasteId) : undefined;
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(original ?? '');
|
||||
}, [pasteId]);
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
setDraft(text);
|
||||
if (pasteId) onSave(pasteId, text);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -27,26 +38,33 @@ export const PastePreviewDialog: React.FC<Props> = ({ pasteId, onClose }) => {
|
||||
Pasted text
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.75rem', mt: 0.25 }}>
|
||||
{chars.toLocaleString()} characters
|
||||
{draft.length.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 || (
|
||||
<Box sx={{ p: 2, bgcolor: c.bg.surface }}>
|
||||
{original === undefined ? (
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.85rem', fontStyle: 'italic' }}>
|
||||
This pasted text is no longer available. Re-paste to restore it.
|
||||
</Typography>
|
||||
) : (
|
||||
<TextField
|
||||
value={draft}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
multiline
|
||||
fullWidth
|
||||
minRows={6}
|
||||
maxRows={20}
|
||||
autoFocus
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: c.font.mono,
|
||||
fontSize: '0.78rem',
|
||||
color: c.text.primary,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: c.border.subtle },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user