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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user