diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 2703b242f..ba4480dae 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -68,27 +68,7 @@ import { deriveDeepReviewSessionConcurrencyGuard } from '../utils/deepReviewCapa import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import './ChatInput.scss'; -// Module-level popup state – used by ModernFlowChatContainer to conditionally -// disable the Escape shortcut so that slash-command and @-mention popups can be -// closed with Escape. -let _chatPopupActive = false; -const _chatPopupListeners = new Set<() => void>(); - -export function isChatPopupActive(): boolean { - return _chatPopupActive; -} - -export function subscribeChatPopupChange(listener: () => void): () => void { - _chatPopupListeners.add(listener); - return () => { _chatPopupListeners.delete(listener); }; -} - -function setChatPopupActive(active: boolean) { - if (_chatPopupActive !== active) { - _chatPopupActive = active; - _chatPopupListeners.forEach(fn => fn()); - } -} +import { setChatPopupActive } from './chatPopupState'; const log = createLogger('ChatInput'); @@ -257,6 +237,7 @@ export const ChatInput: React.FC = ({ const inputValueRef = useRef(''); const pendingLargePastesRef = useRef({}); const largePasteCountersRef = useRef>({}); + const undoImageStackRef = useRef([]); // History navigation state const [historyIndex, setHistoryIndex] = useState(-1); @@ -269,6 +250,9 @@ export const ChatInput: React.FC = ({ const removeContext = useContextStore(state => state.removeContext); const clearContexts = useContextStore(state => state.clearContexts); + const contextsRef = useRef(contexts); + contextsRef.current = contexts; + const imageContexts = useMemo( () => contexts.filter((c): c is ImageContext => c.type === 'image'), [contexts], @@ -345,9 +329,10 @@ export const ChatInput: React.FC = ({ const hasOnlyBr = el.childNodes.length === 1 && (el.childNodes[0] as Element).nodeName === 'BR'; - const isEmpty = (el.textContent ?? '').trim() === '' && + const isDomEmpty = (el.textContent ?? '').trim() === '' && (el.childNodes.length === 0 || hasOnlyBr); - setShowPlaceholder(isEmpty); + const hasContexts = contextsRef.current.length > 0; + setShowPlaceholder(isDomEmpty && !hasContexts); }, []); const measureCapsuleInputWidth = useCallback((): number | null => { @@ -1314,6 +1299,7 @@ export const ChatInput: React.FC = ({ const imageContext = await createImageContextFromClipboard(file); addContext(imageContext); + undoImageStackRef.current.push(imageContext.id); if (!inputState.isActive) { dispatchInput({ type: 'ACTIVATE' }); @@ -2415,6 +2401,21 @@ export const ChatInput: React.FC = ({ return; } + // Ctrl+Z / Cmd+Z: undo last image paste (image pastes bypass the browser's native undo stack) + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') { + const stack = undoImageStackRef.current; + // Skip stale entries (images already removed manually or via clearContexts) + while (stack.length > 0) { + const imageId = stack.pop()!; + if (contextsRef.current.some(c => c.id === imageId)) { + e.preventDefault(); + removeContext(imageId); + return; + } + } + // No valid image to undo; let the browser handle native text undo (do not preventDefault) + } + const nativeEvt = e.nativeEvent as KeyboardEvent; // IME-owned keys must stay with the input method. In particular, Escape // closes the Chinese/Japanese/Korean candidate window and must not cancel @@ -2657,7 +2658,7 @@ export const ChatInput: React.FC = ({ e.preventDefault(); void handleCancelCurrentTask(); } - }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, handleCancelCurrentTask, slashCommandState, getFilteredIncrementalModes, getFilteredActions, getSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashPromptCommand, canSwitchModes, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, t]); + }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, handleCancelCurrentTask, slashCommandState, getFilteredIncrementalModes, getFilteredActions, getSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashPromptCommand, canSwitchModes, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]); const handleImeCompositionStart = useCallback(() => { isImeComposingRef.current = true; diff --git a/src/web-ui/src/flow_chat/components/chatPopupState.ts b/src/web-ui/src/flow_chat/components/chatPopupState.ts new file mode 100644 index 000000000..af2b32c34 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/chatPopupState.ts @@ -0,0 +1,21 @@ +// Module-level popup state – used by ModernFlowChatContainer to conditionally +// disable the Escape shortcut so that slash-command and @-mention popups can be +// closed with Escape. +let _chatPopupActive = false; +const _chatPopupListeners = new Set<() => void>(); + +export function isChatPopupActive(): boolean { + return _chatPopupActive; +} + +export function subscribeChatPopupChange(listener: () => void): () => void { + _chatPopupListeners.add(listener); + return () => { _chatPopupListeners.delete(listener); }; +} + +export function setChatPopupActive(active: boolean) { + if (_chatPopupActive !== active) { + _chatPopupActive = active; + _chatPopupListeners.forEach(fn => fn()); + } +} \ No newline at end of file diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 3b3b0d4b3..9f96a149f 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -23,7 +23,7 @@ import { useFlowChatSearch } from './useFlowChatSearch'; import { useVirtualItems, useActiveSession, useVisibleTurnInfo, type VisibleTurnInfo } from '../../store/modernFlowChatStore'; import type { FlowChatConfig, FlowToolItem, Session, DialogTurn } from '../../types/flow-chat'; import type { LineRange } from '@/component-library'; -import { isChatPopupActive, subscribeChatPopupChange } from '../ChatInput'; +import { isChatPopupActive, subscribeChatPopupChange } from '../chatPopupState'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { parsePullRequestUrl } from '@/shared/utils/pullRequestLinks'; import { createReviewPlatformPullRequestDetailTab } from '@/shared/utils/tabUtils';