Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion frontend/src/app/components/editor/richEditorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand All @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/app/pages/AgentChat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -292,13 +290,16 @@ 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,
addImageFiles, uploadAndAttachFiles, handleSend,
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
Expand All @@ -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}
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/app/pages/AgentChat/ChatInput/hooks/draftStore.ts
Original file line number Diff line number Diff line change
@@ -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>();
Expand All @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createPasteId,
setPasteContent,
deletePasteContent,
updatePasteCardLabel,
detectEditorTrigger,
TriggerState,
EMPTY_TRIGGER,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -349,5 +359,6 @@ export function useEditorHandlers(p: Params) {
updateHasContent,
handleInput, handleEditorClick, handlePickerSelect, handleKeyDown, handlePaste,
handleDragOver, handleDragLeave, handleDrop,
removePasteCard, savePasteCard,
};
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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>
Expand Down
Loading