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
49 changes: 25 additions & 24 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -257,6 +237,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const inputValueRef = useRef('');
const pendingLargePastesRef = useRef<PendingLargePasteMap>({});
const largePasteCountersRef = useRef<Record<number, number>>({});
const undoImageStackRef = useRef<string[]>([]);

// History navigation state
const [historyIndex, setHistoryIndex] = useState(-1);
Expand All @@ -269,6 +250,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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],
Expand Down Expand Up @@ -345,9 +329,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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 => {
Expand Down Expand Up @@ -1314,6 +1299,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const imageContext = await createImageContextFromClipboard(file);

addContext(imageContext);
undoImageStackRef.current.push(imageContext.id);

if (!inputState.isActive) {
dispatchInput({ type: 'ACTIVATE' });
Expand Down Expand Up @@ -2415,6 +2401,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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
Expand Down Expand Up @@ -2657,7 +2658,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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;
Expand Down
21 changes: 21 additions & 0 deletions src/web-ui/src/flow_chat/components/chatPopupState.ts
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading