diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index bd07cf8c77..a72b75a5fa 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -491,6 +491,7 @@ export const ChatInput: React.FC = ({ // Ref so the queuedInput sync effect can read the latest value without it being a dep const inputValueRef = useRef(''); const pendingLargePastesRef = useRef({}); + const [pendingLargePastes, setPendingLargePastes] = useState({}); const composerMutationRevisionsRef = useRef(new Map()); const isRestoringSessionDraftRef = useRef(false); const sessionConflictRetryBaselinesRef = useRef(new Map()); @@ -1758,7 +1759,9 @@ export const ChatInput: React.FC = ({ dispatchLocalInput({ type: 'SET_VALUE', payload: nextValue }); inputValueRef.current = nextValue; - pendingLargePastesRef.current = { ...nextPendingLargePastes }; + const restoredPendingLargePastes = { ...nextPendingLargePastes }; + pendingLargePastesRef.current = restoredPendingLargePastes; + setPendingLargePastes(restoredPendingLargePastes); isRestoringSessionDraftRef.current = true; try { replaceContexts(nextContexts); @@ -1822,6 +1825,7 @@ export const ChatInput: React.FC = ({ markComposerMutation(); } pendingLargePastesRef.current = nextPendingLargePastes; + setPendingLargePastes(nextPendingLargePastes); const sessionId = effectiveTargetSessionIdRef.current; if (sessionId) { @@ -1961,28 +1965,55 @@ export const ChatInput: React.FC = ({ replaceContexts, ]); + const allocateLargePastePlaceholder = useCallback((charCount: number, excluded?: string): string => { + const base = t('input.largePastePlaceholder', { count: charCount }); + let suffix = largePasteCountersRef.current[charCount] ?? 0; + let placeholder: string; + do { + suffix += 1; + placeholder = suffix === 1 ? base : `${base} #${suffix}`; + } while ( + placeholder !== excluded + && Object.prototype.hasOwnProperty.call(pendingLargePastesRef.current, placeholder) + ); + largePasteCountersRef.current[charCount] = suffix; + return placeholder; + }, [t]); + const createLargePastePlaceholder = useCallback((text: string): string | null => { const charCount = getCharacterCount(text); if (charCount <= CHAT_INPUT_CONFIG.largePaste.thresholdChars) { return null; } - const nextCounters = largePasteCountersRef.current; - const nextSuffix = (nextCounters[charCount] ?? 0) + 1; - nextCounters[charCount] = nextSuffix; - - const base = t('input.largePastePlaceholder', { - count: charCount, - }); - const placeholder = nextSuffix === 1 ? base : `${base} #${nextSuffix}`; - + const placeholder = allocateLargePastePlaceholder(charCount); replacePendingLargePastes({ ...pendingLargePastesRef.current, [placeholder]: text, }); return placeholder; - }, [replacePendingLargePastes, t]); + }, [allocateLargePastePlaceholder, replacePendingLargePastes]); + + const updateLargePaste = useCallback((placeholder: string, text: string): string => { + const currentText = pendingLargePastesRef.current[placeholder]; + const charCount = getCharacterCount(text); + const nextPlaceholder = currentText !== undefined && getCharacterCount(currentText) === charCount + ? placeholder + : allocateLargePastePlaceholder(charCount, placeholder); + const nextPendingLargePastes = { ...pendingLargePastesRef.current }; + delete nextPendingLargePastes[placeholder]; + nextPendingLargePastes[nextPlaceholder] = text; + replacePendingLargePastes(nextPendingLargePastes); + return nextPlaceholder; + }, [allocateLargePastePlaceholder, replacePendingLargePastes]); + + const removeLargePaste = useCallback((placeholder: string) => { + if (!Object.prototype.hasOwnProperty.call(pendingLargePastesRef.current, placeholder)) return; + const nextPendingLargePastes = { ...pendingLargePastesRef.current }; + delete nextPendingLargePastes[placeholder]; + replacePendingLargePastes(nextPendingLargePastes); + }, [replacePendingLargePastes]); const prunePendingLargePastes = useCallback((text: string) => { const entries = Object.entries(pendingLargePastesRef.current); @@ -2903,7 +2934,8 @@ export const ChatInput: React.FC = ({ // (EventHandlerModule sets queuedInput on failed turns), NOT for live typing. // Restoring while the user is actively typing would overwrite their draft. log.debug('Detected queuedInput, restoring message to input', { queuedInput }); - clearPendingLargePastes(); + // Keep the session-scoped paste map restored with this draft. Remote and + // detached submissions must still expand placeholders before transport. dispatchInput({ type: 'SET_VALUE', payload: queuedInput }); inputValueRef.current = queuedInput; if (richTextInputRef.current) { @@ -2913,7 +2945,6 @@ export const ChatInput: React.FC = ({ }, [ derivedState?.queuedInput, effectiveTargetSessionId, - clearPendingLargePastes, dispatchInput, ]); @@ -4428,7 +4459,9 @@ export const ChatInput: React.FC = ({ effectiveTargetSessionIdRef.current = newSessionId; dispatchLocalInput({ type: 'SET_VALUE', payload: transferredDraft.value }); inputValueRef.current = transferredDraft.value; - pendingLargePastesRef.current = transferredDraft.pendingLargePastes; + const transferredPendingLargePastes = { ...transferredDraft.pendingLargePastes }; + pendingLargePastesRef.current = transferredPendingLargePastes; + setPendingLargePastes(transferredPendingLargePastes); isRestoringSessionDraftRef.current = true; try { replaceContexts(transferredDraft.contexts); @@ -5711,6 +5744,9 @@ export const ChatInput: React.FC = ({ value={inputState.value} onChange={handleInputChange} onLargePaste={createLargePastePlaceholder} + pendingLargePastes={pendingLargePastes} + onUpdateLargePaste={updateLargePaste} + onRemoveLargePaste={removeLargePaste} onKeyDown={handleKeyDown} onCompositionStart={handleImeCompositionStart} onCompositionEnd={handleImeCompositionEnd} diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.scss b/src/web-ui/src/flow_chat/components/RichTextInput.scss index 7f8ec6e655..9b6d40d331 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.scss +++ b/src/web-ui/src/flow_chat/components/RichTextInput.scss @@ -303,10 +303,81 @@ } } +.rich-text-large-paste { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: min(100%, 320px); + margin: 0 3px; + padding: 2px 6px 2px 8px; + border: 1px solid color-mix(in srgb, var(--bf-color-border-default) 72%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--bf-color-surface-subtle) 82%, transparent); + color: var(--bf-color-content-secondary); + font-family: var(--bf-type-body-sm-font-family); + font-size: var(--bf-type-flow-control-font-size); + font-weight: var(--bf-type-label-sm-font-weight); + line-height: var(--bf-type-flow-support-line-height); + vertical-align: middle; + cursor: pointer; + user-select: none; + transition: + background-color 160ms ease, + border-color 160ms ease, + color 160ms ease; + + &:hover { + border-color: color-mix(in srgb, var(--bf-color-accent-default) 42%, var(--bf-color-border-default)); + background: color-mix(in srgb, var(--bf-color-accent-default) 8%, var(--bf-color-surface-subtle)); + color: var(--bf-color-content-primary); + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--bf-color-accent-hover) 40%, transparent); + outline-offset: 1px; + } +} + +.rich-text-large-paste__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rich-text-large-paste__remove { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: currentColor; + font-size: var(--bf-type-flow-section-title-font-size); + line-height: var(--bf-type-modifier-leading-none-line-height); + opacity: 0.6; + cursor: pointer; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, currentColor 12%, transparent); + opacity: 1; + } +} + +.rich-text-large-paste-dialog__textarea { + min-height: 280px; + font-family: var(--bf-type-code-md-font-family); +} + @media (prefers-reduced-motion: reduce) { .rich-text-placeholder, .rich-text-tag-pill, - .rich-text-tag-pill__remove { + .rich-text-tag-pill__remove, + .rich-text-large-paste { transition-duration: 0ms; } } diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx index 09a3ab16bc..a576a2e994 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx @@ -1,6 +1,7 @@ import React, { act, createRef, forwardRef, useImperativeHandle, useState } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; +import { Simulate } from 'react-dom/test-utils'; import RichTextInput, { type RichTextInputElement } from './RichTextInput'; import type { ContextItem } from '../../shared/types/context'; @@ -69,6 +70,10 @@ describeWithJsdom('RichTextInput external sync', () => { vi.stubGlobal('InputEvent', window.InputEvent); vi.stubGlobal('getSelection', window.getSelection.bind(window)); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + Object.assign(window.HTMLElement.prototype, { + attachEvent: () => {}, + detachEvent: () => {}, + }); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); @@ -592,4 +597,254 @@ describeWithJsdom('RichTextInput external sync', () => { startOffset: 6, }); }); + + it('renders a semantic large-paste capsule without leaking its controls into input text', async () => { + const placeholder = '[Pasted Content 1001 chars]'; + const onChange = vi.fn(); + + await act(async () => { + root.render( + {}} + /> + ); + }); + + const editor = container.querySelector('.rich-text-input') as HTMLDivElement; + const capsule = editor.querySelector('[data-large-paste-placeholder]') as HTMLElement | null; + expect(capsule).toBeTruthy(); + expect(capsule?.getAttribute('contenteditable')).toBe('false'); + expect(capsule?.getAttribute('role')).toBe('button'); + + await act(async () => { + editor.dispatchEvent(new window.Event('input', { bubbles: true })); + }); + + expect(onChange).toHaveBeenLastCalledWith(`before ${placeholder} after`, emptyContexts); + }); + + it('lets users view, copy, cancel, edit, save, and remove a large paste', async () => { + const placeholder = '[Pasted Content 1001 chars]'; + const updatedPlaceholder = '[Pasted Content 1002 chars]'; + const originalText = 'a'.repeat(1001); + const editedText = `${originalText}b`; + const onChange = vi.fn(); + const onUpdateLargePaste = vi.fn(() => updatedPlaceholder); + const onRemoveLargePaste = vi.fn(); + const writeText = vi.fn(async () => {}); + Object.defineProperty(window.navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + + await act(async () => { + root.render( + {}} + /> + ); + }); + + const editor = container.querySelector('.rich-text-input') as HTMLDivElement; + const getCapsule = () => editor.querySelector('[data-large-paste-placeholder]') as HTMLElement; + await act(async () => { + getCapsule().click(); + }); + + let textarea = document.body.querySelector( + '.rich-text-large-paste-dialog__textarea textarea', + ) as HTMLTextAreaElement; + expect(textarea.value).toBe(originalText); + + const dialog = textarea.closest('[role="dialog"]') as HTMLElement; + const dialogButtons = Array.from(dialog.querySelectorAll('button')); + const [copyButton, cancelButton, saveButton] = dialogButtons.slice(-3); + await act(async () => { + copyButton?.click(); + }); + expect(writeText).toHaveBeenCalledWith(originalText); + + await act(async () => { + Simulate.change(textarea, { target: { value: editedText } } as never); + }); + await act(async () => { + cancelButton?.click(); + }); + await act(async () => { + getCapsule().click(); + }); + textarea = document.body.querySelector( + '.rich-text-large-paste-dialog__textarea textarea', + ) as HTMLTextAreaElement; + expect(textarea.value).toBe(originalText); + + await act(async () => { + Simulate.change(textarea, { target: { value: editedText } } as never); + }); + await act(async () => { + saveButton?.click(); + }); + + expect(onUpdateLargePaste).toHaveBeenCalledWith(placeholder, editedText); + expect(getCapsule().dataset.largePastePlaceholder).toBe(updatedPlaceholder); + expect(onChange).toHaveBeenLastCalledWith(updatedPlaceholder, emptyContexts); + + const removeButton = getCapsule().querySelector('button'); + await act(async () => { + removeButton?.click(); + }); + expect(onRemoveLargePaste).toHaveBeenCalledWith(updatedPlaceholder); + expect(editor.querySelector('[data-large-paste-placeholder]')).toBeNull(); + }); + + it('inserts Unicode large pastes as capsules at the caret', async () => { + const text = '文😀'.repeat(501); + const placeholder = '[Pasted Content 1002 chars]'; + const onChange = vi.fn(); + const onLargePaste = vi.fn(() => placeholder); + + await act(async () => { + root.render( + {}} + /> + ); + }); + + const editor = container.querySelector('.rich-text-input') as HTMLDivElement; + setCaret(editor, 0); + const pasteEvent = new window.Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { + items: [], + getData: (type: string) => type === 'text/plain' ? text : '', + }, + }); + await act(async () => { + editor.dispatchEvent(pasteEvent); + }); + + expect(onLargePaste).toHaveBeenCalledWith(text); + expect(editor.querySelector('[data-large-paste-placeholder]')).toBeTruthy(); + expect(onChange).toHaveBeenLastCalledWith(placeholder, emptyContexts); + const selection = window.getSelection(); + expect(selection?.anchorNode?.nodeType).toBe(Node.TEXT_NODE); + expect(selection?.anchorNode?.textContent).toBe('\u200B'); + expect(selection?.anchorOffset).toBe(1); + }); + + it('keeps the caret visually anchored after consecutive large-paste capsules', async () => { + const text = 'x'.repeat(1001); + const placeholders = [ + '[Pasted Content 1001 chars]', + '[Pasted Content 1001 chars] #2', + '[Pasted Content 1001 chars] #3', + ]; + const onChange = vi.fn(); + let pasteIndex = 0; + const onLargePaste = vi.fn(() => placeholders[pasteIndex++] ?? null); + + await act(async () => { + root.render( + {}} + /> + ); + }); + + const editor = container.querySelector('.rich-text-input') as HTMLDivElement; + setCaret(editor, 0); + const dispatchPaste = async () => { + const pasteEvent = new window.Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { + items: [], + getData: (type: string) => type === 'text/plain' ? text : '', + }, + }); + await act(async () => { + editor.dispatchEvent(pasteEvent); + }); + }; + + await dispatchPaste(); + await dispatchPaste(); + await dispatchPaste(); + + expect(editor.querySelectorAll('[data-large-paste-placeholder]')).toHaveLength(3); + expect(onChange).toHaveBeenLastCalledWith(placeholders.join(''), emptyContexts); + const selection = window.getSelection(); + expect(selection?.anchorNode?.nodeType).toBe(Node.TEXT_NODE); + expect(selection?.anchorNode?.textContent).toBe('\u200B'); + expect(selection?.anchorOffset).toBe(1); + + const pressBackspace = async () => { + await act(async () => { + editor.dispatchEvent(new window.KeyboardEvent('keydown', { + key: 'Backspace', + bubbles: true, + cancelable: true, + })); + }); + }; + await pressBackspace(); + expect(editor.querySelectorAll('[data-large-paste-placeholder]')).toHaveLength(2); + expect(selection?.anchorNode?.previousSibling).toBe( + editor.querySelector('[data-large-paste-placeholder]:nth-of-type(2)'), + ); + expect(selection?.anchorOffset).toBe(1); + + await pressBackspace(); + expect(editor.querySelectorAll('[data-large-paste-placeholder]')).toHaveLength(1); + expect(selection?.anchorNode?.previousSibling).toBe( + editor.querySelector('[data-large-paste-placeholder]'), + ); + expect(selection?.anchorOffset).toBe(1); + }); + + it('closes an open large-paste editor when the surface-scoped source changes', async () => { + const placeholder = '[Pasted Content 1001 chars]'; + const renderInput = (text: string) => ( + {}} + pendingLargePastes={{ [placeholder]: text }} + contexts={emptyContexts} + onRemoveContext={() => {}} + /> + ); + + await act(async () => { + root.render(renderInput('surface-a')); + }); + const capsule = container.querySelector('[data-large-paste-placeholder]') as HTMLElement; + await act(async () => { + capsule.click(); + }); + expect(document.body.querySelector('textarea')).toBeTruthy(); + + await act(async () => { + root.render(renderInput('surface-b')); + }); + expect(document.body.querySelector('[role="dialog"][data-state="open"]')).toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.tsx index eefe40b338..ca2f300212 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.tsx @@ -3,10 +3,11 @@ * Supports inserting file tags inline and using @ to select files/folders. */ -import { Icon } from '@bitfun/ui'; +import { Button, Dialog, DialogBody, DialogClose, DialogFooter, DialogHeader, DialogHeading, DialogTitle, Icon, Textarea } from '@bitfun/ui'; import React, { useRef, useEffect, useCallback, useState } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { MessageCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import type { ContextItem } from '../../shared/types/context'; import { getRichTextExternalSyncAction } from './richTextInputSync'; import { @@ -35,6 +36,8 @@ const SKILL_REFERENCE_BADGE_ICON = renderToStaticMarkup( const SESSION_REFERENCE_BADGE_ICON = renderToStaticMarkup(