diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 76fc25e88a..9d95eddfe6 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -108,6 +108,7 @@ Avoid mock-heavy tests that verify implementation details rather than behavior. - Let types drive design: prefer discriminated unions for state, minimize runtime checks, and simplify when types feel unwieldy. - Use `using` declarations (or equivalent disposables) for processes, file handles, etc., to ensure cleanup even on errors. - Centralize magic constants under `src/constants/`; share them instead of duplicating values across layers. +- Never repeat constant values (like keybinds) in comments—they become stale when the constant changes. ## Component State & Storage diff --git a/scripts/bump_tag.sh b/scripts/bump_tag.sh index 069cf9fddd..db16e557a0 100755 --- a/scripts/bump_tag.sh +++ b/scripts/bump_tag.sh @@ -18,7 +18,7 @@ if [[ -z "$CURRENT_VERSION" || "$CURRENT_VERSION" == "null" ]]; then fi # Parse semver components -IFS='.' read -r MAJOR MINOR_V PATCH <<< "$CURRENT_VERSION" +IFS='.' read -r MAJOR MINOR_V PATCH <<<"$CURRENT_VERSION" # Calculate new version if [[ "$MINOR" == "true" ]]; then @@ -30,7 +30,7 @@ fi echo "Bumping version: $CURRENT_VERSION -> $NEW_VERSION" # Update package.json -jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp +jq --arg v "$NEW_VERSION" '.version = $v' package.json >package.json.tmp mv package.json.tmp package.json # Commit and tag diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index c2f6eb5d61..d31db998ef 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -148,6 +148,25 @@ export const ChatInput: React.FC = (props) => { }, []); const inputRef = useRef(null); const modelSelectorRef = useRef(null); + + // Draft state combines text input and image attachments + // Use these helpers to avoid accidentally losing images when modifying text + interface DraftState { + text: string; + images: ImageAttachment[]; + } + const getDraft = useCallback( + (): DraftState => ({ text: input, images: imageAttachments }), + [input, imageAttachments] + ); + const setDraft = useCallback( + (draft: DraftState) => { + setInput(draft.text); + setImageAttachments(draft.images); + }, + [setInput] + ); + const preEditDraftRef = useRef({ text: "", images: [] }); const [mode, setMode] = useMode(); const { recentModels, addModel, defaultModel, setDefaultModel } = useModelLRU(); const commandListId = useId(); @@ -346,10 +365,11 @@ export const ChatInput: React.FC = (props) => { }; }, [focusMessageInput]); - // When entering editing mode, populate input with message content + // When entering editing mode, save current draft and populate with message content useEffect(() => { if (editingMessage) { - setInput(editingMessage.content); + preEditDraftRef.current = getDraft(); + setDraft({ text: editingMessage.content, images: [] }); // Auto-resize textarea and focus setTimeout(() => { if (inputRef.current) { @@ -360,7 +380,8 @@ export const ChatInput: React.FC = (props) => { } }, 0); } - }, [editingMessage, setInput]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- only run when editingMessage changes + }, [editingMessage]); // Watch input for slash commands useEffect(() => { @@ -826,6 +847,15 @@ export const ChatInput: React.FC = (props) => { } }; + // Handler for Escape in vim normal mode - cancels edit if editing + const handleEscapeInNormalMode = () => { + if (variant === "workspace" && editingMessage && props.onCancelEdit) { + setDraft(preEditDraftRef.current); + props.onCancelEdit(); + inputRef.current?.blur(); + } + }; + const handleKeyDown = (e: React.KeyboardEvent) => { // Handle cancel for creation variant if (variant === "creation" && matchesKeybind(e, KEYBINDS.CANCEL) && props.onCancel) { @@ -870,10 +900,13 @@ export const ChatInput: React.FC = (props) => { return; } - // Handle cancel edit (Ctrl+Q) - workspace only + // Handle cancel edit (Escape) - workspace only + // In vim mode, escape first goes to normal mode; escapeInNormalMode callback handles cancel + // In non-vim mode, escape directly cancels edit if (matchesKeybind(e, KEYBINDS.CANCEL_EDIT)) { - if (variant === "workspace" && editingMessage && props.onCancelEdit) { + if (variant === "workspace" && editingMessage && props.onCancelEdit && !vimEnabled) { e.preventDefault(); + setDraft(preEditDraftRef.current); props.onCancelEdit(); const isFocused = document.activeElement === inputRef.current; if (isFocused) { @@ -897,7 +930,6 @@ export const ChatInput: React.FC = (props) => { } // Note: ESC handled by VimTextArea (for mode transitions) and CommandSuggestions (for dismissal) - // Edit canceling is Ctrl+Q, stream interruption is Ctrl+C (vim) or Esc (normal) // Don't handle keys if command suggestions are visible if ( @@ -924,7 +956,10 @@ export const ChatInput: React.FC = (props) => { // Workspace variant placeholders if (editingMessage) { - return `Edit your message... (${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`; + const cancelHint = vimEnabled + ? `${formatKeybind(KEYBINDS.CANCEL_EDIT)}×2 to cancel` + : `${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel`; + return `Edit your message... (${cancelHint}, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`; } if (isCompacting) { const interruptKeybind = vimEnabled @@ -1040,6 +1075,7 @@ export const ChatInput: React.FC = (props) => { onPaste={handlePaste} onDragOver={handleDragOver} onDrop={handleDrop} + onEscapeInNormalMode={handleEscapeInNormalMode} suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined} placeholder={placeholder} disabled={!editingMessage && (disabled || isSending)} @@ -1074,7 +1110,8 @@ export const ChatInput: React.FC = (props) => { {/* Editing indicator - workspace only */} {variant === "workspace" && editingMessage && (
- Editing message ({formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel) + Editing message ({formatKeybind(KEYBINDS.CANCEL_EDIT)} + {vimEnabled ? "×2" : ""} to cancel)
)} diff --git a/src/browser/components/VimTextArea.tsx b/src/browser/components/VimTextArea.tsx index 1abcfa0c5a..227a2244c3 100644 --- a/src/browser/components/VimTextArea.tsx +++ b/src/browser/components/VimTextArea.tsx @@ -32,12 +32,27 @@ export interface VimTextAreaProps isEditing?: boolean; suppressKeys?: string[]; // keys for which Vim should not interfere (e.g. ["Tab","ArrowUp","ArrowDown","Escape"]) when popovers are open trailingAction?: React.ReactNode; + /** Called when Escape is pressed in normal mode (vim) - useful for cancel edit */ + onEscapeInNormalMode?: () => void; } type VimMode = vim.VimMode; export const VimTextArea = React.forwardRef( - ({ value, onChange, mode, isEditing, suppressKeys, onKeyDown, trailingAction, ...rest }, ref) => { + ( + { + value, + onChange, + mode, + isEditing, + suppressKeys, + onKeyDown, + trailingAction, + onEscapeInNormalMode, + ...rest + }, + ref + ) => { const textareaRef = useRef(null); // Expose DOM ref to parent useEffect(() => { @@ -129,7 +144,7 @@ export const VimTextArea = React.forwardRef