Add variant/config cycle keyboard shortcut (vibe-kanban) (#922)

* Perfect! I've successfully implemented the Shift+Tab keyboard shortcut to cycle between agent configurations. Here's a summary of the changes:

Added Shift+Tab keyboard shortcut to cycle through agent configuration variants when editing the follow-up textbox.

1. **TaskFollowUpSection.tsx** (frontend/src/components/tasks/TaskFollowUpSection.tsx:125-136, 400)
   - Added `cycleVariant` function that cycles to the next variant in the current profile
   - Passed `onShiftTab={cycleVariant}` to `FollowUpEditorCard`

2. **FollowUpEditorCard.tsx** (frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx:17, 31, 50)
   - Added `onShiftTab?: () => void` prop to component interface
   - Passed through to `FileSearchTextarea`

3. **file-search-textarea.tsx** (frontend/src/components/ui/file-search-textarea.tsx:25, 40, 294)
   - Added `onShiftTab?: () => void` prop to component interface
   - Passed through to `AutoExpandingTextarea`

4. **auto-expanding-textarea.tsx** (frontend/src/components/ui/auto-expanding-textarea.tsx:8, 21, 62-65, 77)
   - Added `onShiftTab?: () => void` prop to component interface
   - Implemented Shift+Tab handler in `handleKeyDown` that prevents default behavior and calls the callback
   - Added to useCallback dependencies

- When the user presses **Shift+Tab** while editing the follow-up textbox, it cycles to the next agent configuration variant
- The `VariantSelector` component already has bump animation implemented (TaskFollowUpSection.tsx line 23-30 in VariantSelector.tsx) which will trigger on variant change
- The shortcut only works when the textbox is editable and active

The fix ensures that when `selectedVariant` is `null`, the first press of Shift+Tab will explicitly set the variant to the first one in the list (index 0), making the cycling work correctly from the start.

Perfect! I've updated the `cycleVariant` function to treat "DEFAULT" as a regular variant in the cycle. Here's what the fix does:

**The fix ensures that:**
1. When `selectedVariant` is `null`, it's treated as `"DEFAULT"` for lookup purposes
2. All variants including "DEFAULT" are included in the cycling
3. The cycle works: DEFAULT → variant1 → variant2 → ... → DEFAULT
4. When cycling lands on "DEFAULT", it converts back to `null` (since that's what the backend/API expects)

**Why this works:**
- First press from `null` (DEFAULT): Cycles to the first non-DEFAULT variant ✓
- Provides keyboard access to cycle back to DEFAULT ✓
- Treats "DEFAULT" as what it truly is: a configured variant, not a special case ✓
- No hacky filters or special-casing needed ✓

The implementation is clean and treats "DEFAULT" as a first-class variant in the cycling logic, while maintaining the existing API contract where `null` represents DEFAULT.

Perfect! Now the Shift+Tab interception only happens when `onShiftTab` is actually provided. This ensures:

1. **Follow-up text box**: Shift+Tab cycles through variants (has `onShiftTab` handler)
2. **All other textareas**: Shift+Tab works normally for browser focus navigation (no `onShiftTab` handler)

The fix preserves the default browser behavior for all existing uses of `AutoExpandingTextarea` while enabling the variant cycling feature only where it's needed.

Refactor keyboard shortcuts (vibe-kanban 67d268cf)

We did this in the last 4 commits:
Add a shortcut to cycle between agent configurations (previously called profile variants) when typing a follow up. Should be active when the user is editing the follow-up text box.
User shift tab to trigger the shortcut.

However this doesnt use the current pattern we want to use for shortcuts. Figure out the correct way and migrate.

Refactor keyboard shortcuts (vibe-kanban 4d35f44f)

Your colleague already handled one:
Refactoring Summary
The Shift+Tab shortcut was refactored from a prop-drilling approach (passing onShiftTab through multiple components) to the centralized keyboard system using the semantic hook pattern. The new implementation:

Registered the shortcut in registry.ts as CYCLE_VARIANT action bound to shift+tab in the FOLLOW_UP scope
Created a semantic hook useKeyCycleVariant for type-safe usage
Used scope management with enableScope(Scope.FOLLOW_UP) to control when the shortcut is active (only when textarea is focused AND editable)
Removed prop drilling - deleted onShiftTab props from FollowUpEditorCard, FileSearchTextarea, and AutoExpandingTextarea
Pattern for other shortcuts: Add the action/keys to registry.ts, create a semantic hook in hooks.ts, use the hook in your component with appropriate scope, and manage scope activation with enableScope/disableScope based on when the feature should be available. The when clause is only for additional runtime conditions beyond what scope controls.

Can we mirgate the rest of the shortcuts in frontend/src/components/tasks/TaskFollowUpSection.tsx

Fix shortcut regressions (vibe-kanban c07e229f)

To‑Do For Shortcuts

Restore submit shortcut in task dialog (frontend/src/components/dialogs/tasks/TaskFormDialog.tsx:491): reintroduce Cmd+Enter / Cmd+Shift+Enter when focus is in the description FileSearchTextarea. Prefer the new keyboard system—add a semantic hook (likely useKeySubmitTask) scoped appropriately, or, if we don’t have one yet, bind via useKeySubmit while the dialog is active. The binding must only be enabled when the form can submit and the textarea is focused.
Re-enable Cmd+Enter save in inline review comments (frontend/src/components/diff/CommentWidgetLine.tsx:69): same story—bring back the shortcut using our keyboard hooks so reviewers can submit without leaving the textarea.
Fix FileSearchTextarea event propagation (frontend/src/components/ui/file-search-textarea.tsx:12, :235): the component still lists onKeyDown but never calls it. Destructure onKeyDown from props and invoke it at the end of handleKeyDown so parent components can layer their own key handling again.

Fix shortcut regressions (vibe-kanban c07e229f)

To‑Do For Shortcuts

Restore submit shortcut in task dialog (frontend/src/components/dialogs/tasks/TaskFormDialog.tsx:491): reintroduce Cmd+Enter / Cmd+Shift+Enter when focus is in the description FileSearchTextarea. Prefer the new keyboard system—add a semantic hook (likely useKeySubmitTask) scoped appropriately, or, if we don’t have one yet, bind via useKeySubmit while the dialog is active. The binding must only be enabled when the form can submit and the textarea is focused.
Re-enable Cmd+Enter save in inline review comments (frontend/src/components/diff/CommentWidgetLine.tsx:69): same story—bring back the shortcut using our keyboard hooks so reviewers can submit without leaving the textarea.
Fix FileSearchTextarea event propagation (frontend/src/components/ui/file-search-textarea.tsx:12, :235): the component still lists onKeyDown but never calls it. Destructure onKeyDown from props and invoke it at the end of handleKeyDown so parent components can layer their own key handling again.

Fmt

* Perfect! I've added the `isComposing` check to `useKeyboardShortcut.ts` at line 68. This fix ensures that:

## What Was Fixed

The `isComposing` check that was lost during the rebase has been restored, but in the new architecture's proper location:

**Before the rebase (old architecture):**
- Each component (`AutoExpandingTextarea`, `Input`) had its own keyboard handlers
- The `isComposing` check was added to these handlers: `if (e.key === 'Enter' && !e.nativeEvent.isComposing)`

**After the rebase (new architecture):**
- All keyboard shortcuts centralized through `useKeyboardShortcut.ts` → `react-hotkeys-hook`
- The check needed to move to the central handler

**The fix:**
- Added `if (event.isComposing) return;` at line 68 in `useKeyboardShortcut.ts`
- This protects **all** keyboard shortcuts (not just Enter) from firing during IME composition
- Covers all actions: `SUBMIT_FOLLOW_UP`, `SUBMIT_TASK`, `CYCLE_VARIANT`, etc.

## Impact

Now when users input text using IME (Japanese, Chinese, Korean, etc.):
- Pressing Enter to confirm text conversion won't accidentally trigger shortcuts
- Pressing Shift+Tab during composition won't cycle variants
- All keyboard shortcuts properly respect IME composition state

This is actually **better** than the old fix because it applies universally to all shortcuts, not just the ones that were manually checked before.
This commit is contained in:
Alex Netsch
2025-10-06 14:11:33 +01:00
committed by GitHub
parent 6e536e41f4
commit adc362955a
9 changed files with 342 additions and 102 deletions

View File

@@ -34,6 +34,7 @@ import type {
ExecutorProfileId,
} from 'shared/types';
import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { useKeySubmitTask, useKeySubmitTaskAlt, Scope } from '@/keyboard';
interface Task {
id: string;
@@ -86,6 +87,7 @@ export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>(
const [quickstartExpanded, setQuickstartExpanded] =
useState<boolean>(false);
const imageUploadRef = useRef<ImageUploadSectionHandle>(null);
const [isTextareaFocused, setIsTextareaFocused] = useState(false);
const isEditMode = Boolean(task);
@@ -440,7 +442,44 @@ export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>(
modal.hide();
}, [modal]);
// Handle keyboard shortcuts
// Keyboard shortcut handlers
const handlePrimarySubmit = useCallback(
(e?: KeyboardEvent) => {
e?.preventDefault();
if (isEditMode) {
handleSubmit();
} else {
handleCreateAndStart();
}
},
[isEditMode, handleSubmit, handleCreateAndStart]
);
const handleAlternativeSubmit = useCallback(
(e?: KeyboardEvent) => {
e?.preventDefault();
handleSubmit();
},
[handleSubmit]
);
// Register keyboard shortcuts
const canSubmit =
title.trim() !== '' && !isSubmitting && !isSubmittingAndStart;
useKeySubmitTask(handlePrimarySubmit, {
scope: Scope.DIALOG,
enableOnFormTags: ['textarea', 'TEXTAREA'],
when: canSubmit && isTextareaFocused,
preventDefault: true,
});
useKeySubmitTaskAlt(handleAlternativeSubmit, {
scope: Scope.DIALOG,
enableOnFormTags: ['textarea', 'TEXTAREA'],
when: canSubmit && isTextareaFocused,
preventDefault: true,
});
// Handle dialog close attempt
const handleDialogOpenChange = (open: boolean) => {
@@ -497,11 +536,9 @@ export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>(
className="mt-1.5"
disabled={isSubmitting || isSubmittingAndStart}
projectId={projectId}
onCommandEnter={
isEditMode ? handleSubmit : handleCreateAndStart
}
onCommandShiftEnter={handleSubmit}
onPasteFiles={handlePasteImages}
onFocus={() => setIsTextareaFocused(true)}
onBlur={() => setIsTextareaFocused(false)}
/>
</div>

View File

@@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
import { useReview, type ReviewDraft } from '@/contexts/ReviewProvider';
import { Scope, useKeyExit } from '@/keyboard';
import { Scope, useKeyExit, useKeySubmitComment } from '@/keyboard';
import { useHotkeysContext } from 'react-hotkeys-hook';
interface CommentWidgetLineProps {
@@ -41,16 +41,7 @@ export function CommentWidgetLine({
onCancel();
}, [setDraft, widgetKey, onCancel]);
const exitOptions = useMemo(
() => ({
scope: Scope.EDIT_COMMENT,
}),
[]
);
useKeyExit(handleCancel, exitOptions);
const handleSave = () => {
const handleSave = useCallback(() => {
if (value.trim()) {
addComment({
filePath: draft.filePath,
@@ -62,7 +53,31 @@ export function CommentWidgetLine({
}
setDraft(widgetKey, null);
onSave();
};
}, [value, draft, setDraft, widgetKey, onSave, addComment]);
const handleSubmitShortcut = useCallback(
(e?: KeyboardEvent) => {
e?.preventDefault();
handleSave();
},
[handleSave]
);
const exitOptions = useMemo(
() => ({
scope: Scope.EDIT_COMMENT,
}),
[]
);
useKeyExit(handleCancel, exitOptions);
useKeySubmitComment(handleSubmitShortcut, {
scope: Scope.EDIT_COMMENT,
enableOnFormTags: ['textarea', 'TEXTAREA'],
when: value.trim() !== '',
preventDefault: true,
});
return (
<div className="p-4 border-y">
@@ -74,7 +89,6 @@ export function CommentWidgetLine({
maxRows={10}
className="w-full bg-primary text-primary-foreground text-sm font-mono resize-none min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
projectId={projectId}
onCommandEnter={handleSave}
/>
<div className="mt-2 flex gap-2">
<Button size="xs" onClick={handleSave} disabled={!value.trim()}>

View File

@@ -23,6 +23,8 @@ import { cn } from '@/lib/utils';
import { useReview } from '@/contexts/ReviewProvider';
import { useClickedElements } from '@/contexts/ClickedElementsProvider';
import { useEntries } from '@/contexts/EntriesContext';
import { useKeyCycleVariant, useKeySubmitFollowUp, Scope } from '@/keyboard';
import { useHotkeysContext } from 'react-hotkeys-hook';
//
import { VariantSelector } from '@/components/tasks/VariantSelector';
import { FollowUpStatusRow } from '@/components/tasks/FollowUpStatusRow';
@@ -63,6 +65,7 @@ export function TaskFollowUpSection({
generateMarkdown: generateClickedMarkdown,
clearElements: clearClickedElements,
} = useClickedElements();
const { enableScope, disableScope } = useHotkeysContext();
const reviewMarkdown = useMemo(
() => generateReviewMarkdown(),
@@ -118,10 +121,30 @@ export function TaskFollowUpSection({
void imageUploadRef.current?.addFiles(files);
}, []);
// Track whether the follow-up textarea is focused
const [isTextareaFocused, setIsTextareaFocused] = useState(false);
// Variant selection (with keyboard cycling)
const { selectedVariant, setSelectedVariant, currentProfile } =
useDefaultVariant({ processes, profiles: profiles ?? null });
// Cycle to the next variant when Shift+Tab is pressed
const cycleVariant = useCallback(() => {
if (!currentProfile) return;
const variants = Object.keys(currentProfile); // Include DEFAULT
if (variants.length === 0) return;
// Treat null as "DEFAULT" for finding current position
const currentVariantForLookup = selectedVariant ?? 'DEFAULT';
const currentIndex = variants.indexOf(currentVariantForLookup);
const nextIndex = (currentIndex + 1) % variants.length;
const nextVariant = variants[nextIndex];
// Keep using null to represent DEFAULT (backend expects it)
// But for display/cycling purposes, treat DEFAULT as a real option
setSelectedVariant(nextVariant === 'DEFAULT' ? null : nextVariant);
}, [currentProfile, selectedVariant, setSelectedVariant]);
// Queue management (including derived lock flag)
const { onQueue, onUnqueue } = useDraftQueue({
attemptId: selectedAttemptId,
@@ -250,6 +273,90 @@ export function TaskFollowUpSection({
const isEditable =
isDraftLoaded && !isDraftLocked && !isRetryActive && !hasPendingApproval;
// Keyboard shortcut handler - unified submit (send or queue depending on state)
const handleSubmitShortcut = useCallback(
async (e?: KeyboardEvent) => {
e?.preventDefault();
// When attempt is running, queue or unqueue
if (isAttemptRunning) {
if (displayQueued) {
setIsUnqueuing(true);
try {
const ok = await onUnqueue();
if (ok) setQueuedOptimistic(false);
} finally {
setIsUnqueuing(false);
}
} else {
setIsQueuing(true);
try {
const ok = await onQueue();
if (ok) setQueuedOptimistic(true);
} finally {
setIsQueuing(false);
}
}
} else {
// When attempt is idle, send immediately
onSendFollowUp();
}
},
[isAttemptRunning, displayQueued, onQueue, onUnqueue, onSendFollowUp]
);
// Register keyboard shortcuts
useKeyCycleVariant(cycleVariant, {
scope: Scope.FOLLOW_UP,
enableOnFormTags: ['textarea', 'TEXTAREA'],
preventDefault: true,
});
useKeySubmitFollowUp(handleSubmitShortcut, {
scope: Scope.FOLLOW_UP_READY,
enableOnFormTags: ['textarea', 'TEXTAREA'],
when: canSendFollowUp && !isDraftLocked && !isQueuing && !isUnqueuing,
});
// Enable FOLLOW_UP scope when textarea is focused AND editable
useEffect(() => {
if (isEditable && isTextareaFocused) {
enableScope(Scope.FOLLOW_UP);
} else {
disableScope(Scope.FOLLOW_UP);
}
return () => {
disableScope(Scope.FOLLOW_UP);
};
}, [isEditable, isTextareaFocused, enableScope, disableScope]);
// Enable FOLLOW_UP_READY scope when ready to send/queue
useEffect(() => {
const isReady =
isTextareaFocused &&
isEditable &&
isDraftLoaded &&
!isSendingFollowUp &&
!isRetryActive;
if (isReady) {
enableScope(Scope.FOLLOW_UP_READY);
} else {
disableScope(Scope.FOLLOW_UP_READY);
}
return () => {
disableScope(Scope.FOLLOW_UP_READY);
};
}, [
isTextareaFocused,
isEditable,
isDraftLoaded,
isSendingFollowUp,
isRetryActive,
enableScope,
disableScope,
]);
// When a process completes (e.g., agent resolved conflicts), refresh branch status promptly
const prevRunningRef = useRef<boolean>(isAttemptRunning);
useEffect(() => {
@@ -381,9 +488,8 @@ export function TaskFollowUpSection({
}}
disabled={!isEditable}
showLoadingOverlay={isUnqueuing || !isDraftLoaded}
onCommandEnter={onSendFollowUp}
onCommandShiftEnter={onSendFollowUp}
onPasteFiles={handlePasteImages}
onFocusChange={setIsTextareaFocused}
/>
<FollowUpStatusRow
status={{

View File

@@ -2,6 +2,7 @@ import { Loader2 } from 'lucide-react';
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
import { cn } from '@/lib/utils';
import { useProject } from '@/contexts/project-context';
import { useCallback } from 'react';
type Props = {
placeholder: string;
@@ -11,10 +12,9 @@ type Props = {
disabled: boolean;
// Loading overlay
showLoadingOverlay: boolean;
onCommandEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onPasteFiles?: (files: File[]) => void;
textareaClassName?: string;
onFocusChange?: (isFocused: boolean) => void;
};
export function FollowUpEditorCard({
@@ -24,12 +24,20 @@ export function FollowUpEditorCard({
onKeyDown,
disabled,
showLoadingOverlay,
onCommandEnter,
onCommandShiftEnter,
onPasteFiles,
textareaClassName,
onFocusChange,
}: Props) {
const { projectId } = useProject();
const handleFocus = useCallback(() => {
onFocusChange?.(true);
}, [onFocusChange]);
const handleBlur = useCallback(() => {
onFocusChange?.(false);
}, [onFocusChange]);
return (
<div className="relative">
<FileSearchTextarea
@@ -37,13 +45,13 @@ export function FollowUpEditorCard({
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
className={cn('flex-1 min-h-[40px] resize-none', textareaClassName)}
disabled={disabled}
projectId={projectId}
rows={1}
maxRows={6}
onCommandEnter={onCommandEnter}
onCommandShiftEnter={onCommandShiftEnter}
onPasteFiles={onPasteFiles}
/>
{showLoadingOverlay && (

View File

@@ -3,90 +3,67 @@ import { cn } from '@/lib/utils';
interface AutoExpandingTextareaProps extends React.ComponentProps<'textarea'> {
maxRows?: number;
onCommandEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
}
const AutoExpandingTextarea = React.forwardRef<
HTMLTextAreaElement,
AutoExpandingTextareaProps
>(
(
{ className, maxRows = 10, onCommandEnter, onCommandShiftEnter, ...props },
ref
) => {
const internalRef = React.useRef<HTMLTextAreaElement>(null);
>(({ className, maxRows = 10, ...props }, ref) => {
const internalRef = React.useRef<HTMLTextAreaElement>(null);
// Get the actual ref to use
const textareaRef = ref || internalRef;
// Get the actual ref to use
const textareaRef = ref || internalRef;
const adjustHeight = React.useCallback(() => {
const textarea = (textareaRef as React.RefObject<HTMLTextAreaElement>)
.current;
if (!textarea) return;
const adjustHeight = React.useCallback(() => {
const textarea = (textareaRef as React.RefObject<HTMLTextAreaElement>)
.current;
if (!textarea) return;
// Reset height to auto to get the natural height
textarea.style.height = 'auto';
// Reset height to auto to get the natural height
textarea.style.height = 'auto';
// Calculate line height
const style = window.getComputedStyle(textarea);
const lineHeight = parseInt(style.lineHeight) || 20;
const paddingTop = parseInt(style.paddingTop) || 0;
const paddingBottom = parseInt(style.paddingBottom) || 0;
// Calculate line height
const style = window.getComputedStyle(textarea);
const lineHeight = parseInt(style.lineHeight) || 20;
const paddingTop = parseInt(style.paddingTop) || 0;
const paddingBottom = parseInt(style.paddingBottom) || 0;
// Calculate max height based on maxRows
const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom;
// Calculate max height based on maxRows
const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom;
// Set the height to scrollHeight, but cap at maxHeight
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
}, [maxRows]);
// Set the height to scrollHeight, but cap at maxHeight
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
}, [maxRows]);
// Adjust height on mount and when content changes
React.useEffect(() => {
// Adjust height on mount and when content changes
React.useEffect(() => {
adjustHeight();
}, [adjustHeight, props.value]);
// Adjust height on input
const handleInput = React.useCallback(
(e: React.FormEvent<HTMLTextAreaElement>) => {
adjustHeight();
}, [adjustHeight, props.value]);
if (props.onInput) {
props.onInput(e);
}
},
[adjustHeight, props.onInput]
);
// Handle keyboard shortcuts
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
if (e.metaKey && e.shiftKey) {
onCommandShiftEnter?.(e);
} else if (e.metaKey) {
onCommandEnter?.(e);
}
}
props.onKeyDown?.(e);
},
[onCommandEnter, onCommandShiftEnter, props.onKeyDown]
);
// Adjust height on input
const handleInput = React.useCallback(
(e: React.FormEvent<HTMLTextAreaElement>) => {
adjustHeight();
if (props.onInput) {
props.onInput(e);
}
},
[adjustHeight, props.onInput]
);
return (
<textarea
className={cn(
'bg-muted p-0 min-h-[80px] w-full text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50 resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words',
className
)}
ref={textareaRef}
onInput={handleInput}
{...props}
onKeyDown={handleKeyDown}
/>
);
}
);
return (
<textarea
className={cn(
'bg-muted p-0 min-h-[80px] w-full text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50 resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words',
className
)}
ref={textareaRef}
onInput={handleInput}
{...props}
/>
);
});
AutoExpandingTextarea.displayName = 'AutoExpandingTextarea';

View File

@@ -19,9 +19,9 @@ interface FileSearchTextareaProps {
projectId?: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
maxRows?: number;
onCommandEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onPasteFiles?: (files: File[]) => void;
onFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
}
export function FileSearchTextarea({
@@ -32,10 +32,11 @@ export function FileSearchTextarea({
disabled = false,
className,
projectId,
onCommandEnter,
onCommandShiftEnter,
onKeyDown,
maxRows = 10,
onPasteFiles,
onFocus,
onBlur,
}: FileSearchTextareaProps) {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<FileSearchResult[]>([]);
@@ -270,6 +271,9 @@ export function FileSearchTextarea({
break;
}
}
// Propagate event to parent component for additional handling
onKeyDown?.(e);
};
return (
@@ -286,9 +290,9 @@ export function FileSearchTextarea({
className={className}
maxRows={maxRows}
onKeyDown={handleKeyDown}
onCommandEnter={onCommandEnter}
onCommandShiftEnter={onCommandShiftEnter}
onPaste={handlePaste}
onFocus={onFocus}
onBlur={onBlur}
/>
{showDropdown &&

View File

@@ -63,6 +63,12 @@ export function useKeyboardShortcut(
useHotkeys(
keys,
(event) => {
// Skip if IME composition is in progress (e.g., Japanese, Chinese, Korean input)
// This prevents shortcuts from firing when user is converting text with Enter
if (event.isComposing) {
return;
}
const w = whenRef.current;
const enabled = typeof w === 'function' ? !!w() : !!w;
if (enabled) callbackRef.current?.(event as KeyboardEvent);

View File

@@ -108,3 +108,47 @@ export const useKeyApproveRequest = createSemanticHook(Action.APPROVE_REQUEST);
* useKeyDenyApproval(() => denyPendingRequest(), { scope: Scope.GLOBAL });
*/
export const useKeyDenyApproval = createSemanticHook(Action.DENY_APPROVAL);
/**
* Cycle variant action - typically Shift+Tab
*
* @example
* useKeyCycleVariant(() => cycleToNextVariant(), { scope: Scope.FOLLOW_UP });
*/
export const useKeyCycleVariant = createSemanticHook(Action.CYCLE_VARIANT);
/**
* Submit follow-up action - typically Cmd+Enter
* Intelligently sends or queues based on current state (running vs idle)
*
* @example
* useKeySubmitFollowUp(() => handleSubmit(), { scope: Scope.FOLLOW_UP_READY });
*/
export const useKeySubmitFollowUp = createSemanticHook(Action.SUBMIT_FOLLOW_UP);
/**
* Submit task action - typically Cmd+Enter
* Primary submit action in task dialog (Create & Start or Update)
*
* @example
* useKeySubmitTask(() => handleSubmit(), { scope: Scope.DIALOG, when: canSubmit });
*/
export const useKeySubmitTask = createSemanticHook(Action.SUBMIT_TASK);
/**
* Alternative task submit action - typically Cmd+Shift+Enter
* Secondary submit action in task dialog (Create Task without starting)
*
* @example
* useKeySubmitTaskAlt(() => handleCreateOnly(), { scope: Scope.DIALOG, when: canSubmit });
*/
export const useKeySubmitTaskAlt = createSemanticHook(Action.SUBMIT_TASK_ALT);
/**
* Submit comment action - typically Cmd+Enter
* Submit review comment in diff view
*
* @example
* useKeySubmitComment(() => handleSave(), { scope: Scope.EDIT_COMMENT, when: hasContent });
*/
export const useKeySubmitComment = createSemanticHook(Action.SUBMIT_COMMENT);

View File

@@ -5,6 +5,8 @@ export enum Scope {
PROJECTS = 'projects',
EDIT_COMMENT = 'edit-comment',
APPROVALS = 'approvals',
FOLLOW_UP = 'follow-up',
FOLLOW_UP_READY = 'follow-up-ready',
}
export enum Action {
@@ -22,6 +24,11 @@ export enum Action {
DELETE_TASK = 'delete_task',
APPROVE_REQUEST = 'approve_request',
DENY_APPROVAL = 'deny_approval',
CYCLE_VARIANT = 'cycle_variant',
SUBMIT_FOLLOW_UP = 'submit_follow_up',
SUBMIT_TASK = 'submit_task',
SUBMIT_TASK_ALT = 'submit_task_alt',
SUBMIT_COMMENT = 'submit_comment',
}
export interface KeyBinding {
@@ -167,6 +174,43 @@ export const keyBindings: KeyBinding[] = [
description: 'Deny pending approval request',
group: 'Approvals',
},
// Follow-up actions
{
action: Action.CYCLE_VARIANT,
keys: 'shift+tab',
scopes: [Scope.FOLLOW_UP],
description: 'Cycle between agent configurations',
group: 'Follow-up',
},
{
action: Action.SUBMIT_FOLLOW_UP,
keys: 'meta+enter',
scopes: [Scope.FOLLOW_UP_READY],
description: 'Send or queue follow-up (depending on state)',
group: 'Follow-up',
},
{
action: Action.SUBMIT_TASK,
keys: ['meta+enter', 'ctrl+enter'],
scopes: [Scope.DIALOG],
description: 'Submit task form (Create & Start or Update)',
group: 'Dialog',
},
{
action: Action.SUBMIT_TASK_ALT,
keys: ['meta+shift+enter', 'ctrl+shift+enter'],
scopes: [Scope.DIALOG],
description: 'Submit task form (Create Task)',
group: 'Dialog',
},
{
action: Action.SUBMIT_COMMENT,
keys: ['meta+enter', 'ctrl+enter'],
scopes: [Scope.EDIT_COMMENT],
description: 'Submit review comment',
group: 'Comments',
},
];
/**