feat: improved chat input keybinding (#173)

This commit is contained in:
Carlos Florêncio
2026-02-04 00:27:44 +00:00
committed by GitHub
parent d9ff49f160
commit da88e667b0
24 changed files with 327 additions and 38 deletions
@@ -51,6 +51,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
PreferredShell: req.PreferredShell,
DefaultEditorID: req.DefaultEditorID,
EnablePreviewOnClick: req.EnablePreviewOnClick,
ChatSubmitKey: req.ChatSubmitKey,
})
if err != nil {
return dto.UserSettingsResponse{}, err
+3
View File
@@ -22,6 +22,7 @@ type UserSettingsDTO struct {
PreferredShell string `json:"preferred_shell"`
DefaultEditorID string `json:"default_editor_id"`
EnablePreviewOnClick bool `json:"enable_preview_on_click"`
ChatSubmitKey string `json:"chat_submit_key"`
UpdatedAt string `json:"updated_at"`
}
@@ -48,6 +49,7 @@ type UpdateUserSettingsRequest struct {
PreferredShell *string `json:"preferred_shell,omitempty"`
DefaultEditorID *string `json:"default_editor_id,omitempty"`
EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"`
ChatSubmitKey *string `json:"chat_submit_key,omitempty"`
}
func FromUser(user *models.User) UserDTO {
@@ -69,6 +71,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
PreferredShell: settings.PreferredShell,
DefaultEditorID: settings.DefaultEditorID,
EnablePreviewOnClick: settings.EnablePreviewOnClick,
ChatSubmitKey: settings.ChatSubmitKey,
UpdatedAt: settings.UpdatedAt.Format(time.RFC3339),
}
}
@@ -71,6 +71,7 @@ type httpUpdateUserSettingsRequest struct {
PreferredShell *string `json:"preferred_shell,omitempty"`
DefaultEditorID *string `json:"default_editor_id,omitempty"`
EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"`
ChatSubmitKey *string `json:"chat_submit_key,omitempty"`
}
func (h *Handlers) httpUpdateUserSettings(c *gin.Context) {
@@ -87,6 +88,7 @@ func (h *Handlers) httpUpdateUserSettings(c *gin.Context) {
PreferredShell: body.PreferredShell,
DefaultEditorID: body.DefaultEditorID,
EnablePreviewOnClick: body.EnablePreviewOnClick,
ChatSubmitKey: body.ChatSubmitKey,
})
if err != nil {
h.logger.Error("failed to update user settings", zap.Error(err))
@@ -112,6 +114,7 @@ type wsUpdateUserSettingsRequest struct {
PreferredShell *string `json:"preferred_shell,omitempty"`
DefaultEditorID *string `json:"default_editor_id,omitempty"`
EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"`
ChatSubmitKey *string `json:"chat_submit_key,omitempty"`
}
func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -127,6 +130,7 @@ func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*
PreferredShell: req.PreferredShell,
DefaultEditorID: req.DefaultEditorID,
EnablePreviewOnClick: req.EnablePreviewOnClick,
ChatSubmitKey: req.ChatSubmitKey,
})
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update user settings", nil)
@@ -18,6 +18,7 @@ type UserSettings struct {
PreferredShell string `json:"preferred_shell"`
DefaultEditorID string `json:"default_editor_id"`
EnablePreviewOnClick bool `json:"enable_preview_on_click"`
ChatSubmitKey string `json:"chat_submit_key"` // "enter" | "cmd_enter"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -31,6 +31,7 @@ type UpdateUserSettingsRequest struct {
PreferredShell *string
DefaultEditorID *string
EnablePreviewOnClick *bool
ChatSubmitKey *string
}
func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service {
@@ -92,6 +93,14 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting
if req.EnablePreviewOnClick != nil {
settings.EnablePreviewOnClick = *req.EnablePreviewOnClick
}
if req.ChatSubmitKey != nil {
key := strings.TrimSpace(*req.ChatSubmitKey)
if key != "enter" && key != "cmd_enter" {
return nil, errors.New("chat_submit_key must be 'enter' or 'cmd_enter'")
}
s.logger.Info("[Settings] Setting ChatSubmitKey", zap.String("value", key))
settings.ChatSubmitKey = key
}
settings.UpdatedAt = time.Now().UTC()
if err := s.repo.UpsertUserSettings(ctx, settings); err != nil {
return nil, err
@@ -113,6 +122,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
"preferred_shell": settings.PreferredShell,
"default_editor_id": settings.DefaultEditorID,
"enable_preview_on_click": settings.EnablePreviewOnClick,
"chat_submit_key": settings.ChatSubmitKey,
"updated_at": settings.UpdatedAt.Format(time.RFC3339),
}
if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil {
@@ -127,6 +127,7 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod
"preferred_shell": settings.PreferredShell,
"default_editor_id": settings.DefaultEditorID,
"enable_preview_on_click": settings.EnablePreviewOnClick,
"chat_submit_key": settings.ChatSubmitKey,
})
if err != nil {
return err
@@ -172,6 +173,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
PreferredShell string `json:"preferred_shell"`
DefaultEditorID string `json:"default_editor_id"`
EnablePreviewOnClick bool `json:"enable_preview_on_click"`
ChatSubmitKey string `json:"chat_submit_key"`
}
if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil {
return nil, err
@@ -183,5 +185,10 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
settings.PreferredShell = payload.PreferredShell
settings.DefaultEditorID = payload.DefaultEditorID
settings.EnablePreviewOnClick = payload.EnablePreviewOnClick
settings.ChatSubmitKey = payload.ChatSubmitKey
// Default to "cmd_enter" if empty
if settings.ChatSubmitKey == "" {
settings.ChatSubmitKey = "cmd_enter"
}
return settings, nil
}
+1
View File
@@ -58,6 +58,7 @@ export default async function Page({ searchParams }: PageProps) {
shellOptions: userSettingsResponse?.shell_options ?? [],
defaultEditorId: userSettings?.default_editor_id || null,
enablePreviewOnClick: settingsEnablePreviewOnClick,
chatSubmitKey: userSettings?.chat_submit_key ?? 'cmd_enter',
loaded: Boolean(userSettings),
},
};
+15 -1
View File
@@ -4,6 +4,7 @@ import {
fetchBoardSnapshot,
fetchTaskSession,
fetchTask,
fetchUserSettings,
listAgents,
listAvailableAgents,
listBoards,
@@ -38,7 +39,7 @@ export default async function SessionPage({
}
task = await fetchTask(session.task_id, { cache: 'no-store' });
const [snapshot, agents, repositoriesResponse, allSessionsResponse, availableAgentsResponse, workspacesResponse, boardsResponse, turnsResponse] = await Promise.all([
const [snapshot, agents, repositoriesResponse, allSessionsResponse, availableAgentsResponse, workspacesResponse, boardsResponse, turnsResponse, userSettingsResponse] = await Promise.all([
fetchBoardSnapshot(task.board_id, { cache: 'no-store' }),
listAgents({ cache: 'no-store' }),
listRepositories(task.workspace_id, { includeScripts: true }, { cache: 'no-store' }),
@@ -47,6 +48,7 @@ export default async function SessionPage({
listWorkspaces({ cache: 'no-store' }).catch(() => ({ workspaces: [] })),
listBoards(task.workspace_id, { cache: 'no-store' }).catch(() => ({ boards: [] })),
listSessionTurns(sessionId, { cache: 'no-store' }).catch(() => ({ turns: [], total: 0 })),
fetchUserSettings({ cache: 'no-store' }).catch(() => null),
]);
const repositories = repositoriesResponse.repositories ?? [];
const allSessions = allSessionsResponse.sessions ?? [session];
@@ -54,6 +56,7 @@ export default async function SessionPage({
const workspaces = workspacesResponse.workspaces ?? [];
const boards = boardsResponse.boards ?? [];
const turns = turnsResponse.turns ?? [];
const userSettings = userSettingsResponse?.settings;
// Get repository scripts from the repository (already fetched with includeScripts)
const repositoryId = task.repositories?.[0]?.repository_id;
@@ -203,6 +206,17 @@ export default async function SessionPage({
loaded: true,
loading: false,
},
userSettings: {
workspaceId: userSettings?.workspace_id || null,
boardId: userSettings?.board_id || null,
repositoryIds: Array.from(new Set(userSettings?.repository_ids ?? [])).sort(),
preferredShell: userSettings?.preferred_shell || null,
shellOptions: userSettingsResponse?.shell_options ?? [],
defaultEditorId: userSettings?.default_editor_id || null,
enablePreviewOnClick: userSettings?.enable_preview_on_click ?? false,
chatSubmitKey: userSettings?.chat_submit_key ?? 'cmd_enter',
loaded: Boolean(userSettings),
},
};
} catch (error) {
console.warn(
+2
View File
@@ -16,6 +16,8 @@ export default async function GeneralSettingsPage() {
preferredShell: settings.preferred_shell ?? null,
shellOptions: response.shell_options ?? [],
defaultEditorId: settings.default_editor_id ?? null,
enablePreviewOnClick: settings.enable_preview_on_click ?? false,
chatSubmitKey: settings.chat_submit_key ?? 'cmd_enter',
loaded: true,
}
: undefined,
@@ -370,6 +370,7 @@ export function EditorsSettings() {
shellOptions: currentUserSettings.shellOptions ?? [],
defaultEditorId: response.settings.default_editor_id || null,
enablePreviewOnClick: response.settings.enable_preview_on_click ?? false,
chatSubmitKey: response.settings.chat_submit_key ?? 'cmd_enter',
loaded: true,
});
}
@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useTheme } from 'next-themes';
import { IconPalette, IconServer } from '@tabler/icons-react';
import { IconPalette, IconServer, IconKeyboard } from '@tabler/icons-react';
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
import { Label } from '@kandev/ui/label';
import { Input } from '@kandev/ui/input';
@@ -11,6 +11,8 @@ import { Separator } from '@kandev/ui/separator';
import { SettingsSection } from '@/components/settings/settings-section';
import { ShellSettingsCard } from '@/components/settings/shell-settings-card';
import { getBackendConfig } from '@/lib/config';
import { useAppStore } from '@/components/state-provider';
import { updateUserSettings } from '@/lib/api';
import type { Theme } from '@/lib/settings/types';
export function GeneralSettings() {
@@ -19,6 +21,39 @@ export function GeneralSettings() {
const displayBackendUrl = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
const themeValue = currentTheme ?? 'system';
// User settings
const userSettings = useAppStore((state) => state.userSettings);
const setUserSettings = useAppStore((state) => state.setUserSettings);
const [isSavingSubmitKey, setIsSavingSubmitKey] = useState(false);
const handleChatSubmitKeyChange = async (value: 'enter' | 'cmd_enter') => {
if (isSavingSubmitKey) return;
setIsSavingSubmitKey(true);
const previousValue = userSettings.chatSubmitKey;
try {
setUserSettings({
...userSettings,
chatSubmitKey: value,
});
await updateUserSettings({
workspace_id: userSettings.workspaceId || '',
board_id: userSettings.boardId || '',
repository_ids: userSettings.repositoryIds || [],
chat_submit_key: value,
});
} catch {
setUserSettings({
...userSettings,
chatSubmitKey: previousValue,
});
} finally {
setIsSavingSubmitKey(false);
}
};
return (
<div className="space-y-8">
<div>
@@ -62,6 +97,43 @@ export function GeneralSettings() {
<Separator />
<SettingsSection
icon={<IconKeyboard className="h-5 w-5" />}
title="Chat Input"
description="Configure chat input behavior"
>
<Card>
<CardHeader>
<CardTitle className="text-base">Submit Shortcut</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="chat-submit-key">Message Submit Key</Label>
<Select
value={userSettings.chatSubmitKey}
onValueChange={(value) => handleChatSubmitKeyChange(value as 'enter' | 'cmd_enter')}
disabled={isSavingSubmitKey}
>
<SelectTrigger id="chat-submit-key">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cmd_enter">Cmd/Ctrl+Enter to send</SelectItem>
<SelectItem value="enter">Enter to send</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{userSettings.chatSubmitKey === 'cmd_enter'
? 'Press Cmd/Ctrl+Enter to send messages. Press Enter for newlines.'
: 'Press Enter to send messages. Press Shift+Enter for newlines.'}
</p>
</div>
</CardContent>
</Card>
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconServer className="h-5 w-5" />}
title="Backend Connection"
@@ -1,6 +1,6 @@
'use client';
import { useRef, useCallback, useState, useEffect, memo, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useRef, useCallback, useState, useEffect, memo, forwardRef, useImperativeHandle, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import {
IconArrowUp,
IconBrain,
@@ -27,6 +27,7 @@ import { MentionMenu } from './mention-menu';
import { SlashCommandMenu } from './slash-command-menu';
import { ClarificationInputOverlay } from './clarification-input-overlay';
import { CommentBlocksContainer } from './comment-block';
import { ChatInputFocusHint } from './chat-input-focus-hint';
import { useInlineMention } from '@/hooks/use-inline-mention';
import { useInlineSlash } from '@/hooks/use-inline-slash';
import type { Message } from '@/lib/types/http';
@@ -45,6 +46,7 @@ type ChatInputToolbarProps = {
isSending: boolean;
onCancel: () => void;
onSubmit: () => void;
submitKey?: 'enter' | 'cmd_enter';
};
const ChatInputToolbar = memo(function ChatInputToolbar({
@@ -59,7 +61,10 @@ const ChatInputToolbar = memo(function ChatInputToolbar({
isSending,
onCancel,
onSubmit,
submitKey = 'cmd_enter',
}: ChatInputToolbarProps) {
const submitShortcut = submitKey === 'enter' ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT;
return (
<div className="flex items-center gap-1 px-2 py-2 border-t border-border">
{/* Left: Plan, Thinking, Context */}
@@ -129,7 +134,7 @@ const ChatInputToolbar = memo(function ChatInputToolbar({
{/* Submit/Stop button */}
<div className="ml-1">
<KeyboardShortcutTooltip shortcut={SHORTCUTS.SUBMIT} enabled={!isAgentBusy && !isDisabled}>
<KeyboardShortcutTooltip shortcut={submitShortcut} enabled={!isAgentBusy && !isDisabled}>
{isAgentBusy ? (
<Button
type="button"
@@ -164,6 +169,14 @@ const ChatInputToolbar = memo(function ChatInputToolbar({
);
});
export type ChatInputContainerHandle = {
focusInput: () => void;
getTextareaElement: () => HTMLTextAreaElement | null;
getValue: () => string;
getSelectionStart: () => number;
insertText: (text: string, from: number, to: number) => void;
};
type ChatInputContainerProps = {
onSubmit: (message: string, reviewComments?: DiffComment[]) => void;
sessionId: string | null;
@@ -189,34 +202,60 @@ type ChatInputContainerProps = {
onRemoveComment?: (sessionId: string, filePath: string, commentId: string) => void;
/** Callback when a comment is clicked (for jump-to-line) */
onCommentClick?: (comment: DiffComment) => void;
/** Chat submit key preference */
submitKey?: 'enter' | 'cmd_enter';
/** Whether the current agent has commands (affects placeholder) */
hasAgentCommands?: boolean;
};
export function ChatInputContainer({
onSubmit,
sessionId,
taskId,
taskTitle,
taskDescription,
planModeEnabled,
onPlanModeChange,
isAgentBusy,
isStarting,
isSending,
onCancel,
placeholder,
pendingClarification,
onClarificationResolved,
showRequestChangesTooltip = false,
onRequestChangesTooltipDismiss,
pendingCommentsByFile,
onRemoveCommentFile,
onRemoveComment,
onCommentClick,
}: ChatInputContainerProps) {
export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInputContainerProps>(
function ChatInputContainer(
{
onSubmit,
sessionId,
taskId,
taskTitle,
taskDescription,
planModeEnabled,
onPlanModeChange,
isAgentBusy,
isStarting,
isSending,
onCancel,
placeholder,
pendingClarification,
onClarificationResolved,
showRequestChangesTooltip = false,
onRequestChangesTooltipDismiss,
pendingCommentsByFile,
onRemoveCommentFile,
onRemoveComment,
onCommentClick,
submitKey = 'cmd_enter',
hasAgentCommands = false,
},
ref
) {
// Keep input state local to avoid re-rendering parent on every keystroke
const [value, setValue] = useState('');
const [isInputFocused, setIsInputFocused] = useState(false);
const inputRef = useRef<RichTextInputHandle>(null);
// Expose imperative handle for parent
useImperativeHandle(
ref,
() => ({
focusInput: () => inputRef.current?.focus(),
getTextareaElement: () => inputRef.current?.getTextareaElement() ?? null,
getValue: () => inputRef.current?.getValue() ?? '',
getSelectionStart: () => inputRef.current?.getSelectionStart() ?? 0,
insertText: (text: string, from: number, to: number) => {
inputRef.current?.insertText(text, from, to);
},
}),
[]
);
// Focus input when request changes tooltip is shown
useEffect(() => {
if (showRequestChangesTooltip && inputRef.current) {
@@ -315,16 +354,28 @@ export function ChatInputContainer({
const hasPendingClarification = pendingClarification && onClarificationResolved;
const hasPendingComments = pendingCommentsByFile && Object.keys(pendingCommentsByFile).length > 0;
// Dynamic placeholder
const inputPlaceholder =
placeholder ||
(hasAgentCommands
? 'Ask to make changes, @mention files, run /commands'
: 'Ask to make changes, @mention files');
// Show focus hint when input not focused, no clarification, and no comments
const showFocusHint = !isInputFocused && !hasPendingClarification && !hasPendingComments;
return (
<div
className={cn(
'rounded-2xl border border-border bg-background shadow-md overflow-hidden',
'relative rounded-2xl border border-border bg-background shadow-md overflow-hidden',
planModeEnabled && 'border-primary border-dashed',
hasPendingClarification && 'border-blue-500/50',
showRequestChangesTooltip && 'animate-pulse border-orange-500',
hasPendingComments && 'border-amber-500/50'
)}
>
{/* Focus hint */}
<ChatInputFocusHint visible={showFocusHint} />
{/* Popup menus */}
<MentionMenu
isOpen={mention.isOpen}
@@ -379,9 +430,12 @@ export function ChatInputContainer({
onChange={handleChange}
onKeyDown={handleKeyDown}
onSubmit={handleSubmit}
placeholder={placeholder}
placeholder={inputPlaceholder}
disabled={isDisabled}
planModeEnabled={planModeEnabled}
submitKey={submitKey}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
/>
</div>
</TooltipTrigger>
@@ -404,7 +458,8 @@ export function ChatInputContainer({
isSending={isSending}
onCancel={onCancel}
onSubmit={handleSubmit}
submitKey={submitKey}
/>
</div>
);
}
});
@@ -0,0 +1,31 @@
'use client';
import { cn } from '@/lib/utils';
type ChatInputFocusHintProps = {
visible: boolean;
className?: string;
};
export function ChatInputFocusHint({ visible, className }: ChatInputFocusHintProps) {
if (!visible) return null;
return (
<div
className={cn(
'absolute top-2 right-2 z-10',
'flex items-center gap-1.5',
'px-2 py-1',
'text-xs text-muted-foreground/60',
'pointer-events-none',
'transition-opacity duration-200',
className
)}
>
<kbd className="px-1 py-0.5 text-xs font-medium text-muted-foreground/70 border border-border/40 rounded">
/
</kbd>
<span>to focus</span>
</div>
);
}
@@ -21,6 +21,7 @@ export type RichTextInputHandle = {
getValue: () => string;
setValue: (value: string) => void;
insertText: (text: string, from: number, to: number) => void;
getTextareaElement: () => HTMLTextAreaElement | null;
};
type RichTextInputProps = {
@@ -32,6 +33,9 @@ type RichTextInputProps = {
disabled?: boolean;
className?: string;
planModeEnabled?: boolean;
submitKey?: 'enter' | 'cmd_enter';
onFocus?: () => void;
onBlur?: () => void;
};
export const RichTextInput = forwardRef<RichTextInputHandle, RichTextInputProps>(
@@ -45,6 +49,9 @@ export const RichTextInput = forwardRef<RichTextInputHandle, RichTextInputProps>
disabled = false,
className,
planModeEnabled = false,
submitKey = 'cmd_enter',
onFocus,
onBlur,
},
ref
) {
@@ -164,6 +171,7 @@ export const RichTextInput = forwardRef<RichTextInputHandle, RichTextInputProps>
textareaRef.current?.setSelectionRange(from + text.length, from + text.length);
});
},
getTextareaElement: () => textareaRef.current,
}),
[getCaretRect, onChange, value]
);
@@ -179,11 +187,21 @@ export const RichTextInput = forwardRef<RichTextInputHandle, RichTextInputProps>
if (event.defaultPrevented) return;
}
// Submit only on Cmd/Ctrl+Enter, and only if not disabled
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
if (!disabled) {
onSubmit?.();
if (submitKey === 'enter') {
// Submit on Enter (unless Shift for newline)
if (event.key === 'Enter' && !event.shiftKey && !event.metaKey && !event.ctrlKey) {
event.preventDefault();
if (!disabled) {
onSubmit?.();
}
}
} else {
// Submit on Cmd/Ctrl+Enter (current behavior)
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
if (!disabled) {
onSubmit?.();
}
}
}
};
@@ -194,6 +212,8 @@ export const RichTextInput = forwardRef<RichTextInputHandle, RichTextInputProps>
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
disabled={disabled}
className={cn(
+50 -1
View File
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useRef, useState, memo } from 'react';
import { getWebSocketClient } from '@/lib/ws/connection';
import { useAppStore } from '@/components/state-provider';
import { getLocalStorage } from '@/lib/local-storage';
import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut';
import { SHORTCUTS } from '@/lib/keyboard/constants';
import { useSessionMessages } from '@/hooks/domains/session/use-session-messages';
import { useSettingsData } from '@/hooks/domains/settings/use-settings-data';
import { useSessionState } from '@/hooks/domains/session/use-session-state';
@@ -12,7 +14,7 @@ import { useSessionModel } from '@/hooks/domains/session/use-session-model';
import { useMessageHandler } from '@/hooks/use-message-handler';
import { TodoSummary } from '@/components/task/chat/todo-summary';
import { VirtualizedMessageList } from '@/components/task/chat/virtualized-message-list';
import { ChatInputContainer } from '@/components/task/chat/chat-input-container';
import { ChatInputContainer, type ChatInputContainerHandle } from '@/components/task/chat/chat-input-container';
import { formatReviewCommentsAsMarkdown } from '@/components/task/chat/messages/review-comments-attachment';
import {
useDiffCommentsStore,
@@ -40,6 +42,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
}: TaskChatPanelProps) {
const [isSending, setIsSending] = useState(false);
const lastAgentMessageCountRef = useRef(0);
const chatInputRef = useRef<ChatInputContainerHandle>(null);
// Ensure agent profile data is loaded (may not be hydrated from SSR in all navigation paths)
useSettingsData(true);
@@ -104,6 +107,13 @@ export const TaskChatPanel = memo(function TaskChatPanel({
session?.agent_profile_id
);
// User settings
const chatSubmitKey = useAppStore((state) => state.userSettings.chatSubmitKey);
const agentCommands = useAppStore((state) =>
resolvedSessionId ? state.availableCommands.bySessionId[resolvedSessionId] : undefined
);
const hasAgentCommands = agentCommands && agentCommands.length > 0;
// Message sending
const { handleSendMessage } = useMessageHandler(
resolvedSessionId,
@@ -183,6 +193,42 @@ export const TaskChatPanel = memo(function TaskChatPanel({
}
}, [isSending, onSend, handleSendMessage, markCommentsSent]);
// Focus input with / shortcut (only when input is NOT focused)
useKeyboardShortcut(
SHORTCUTS.FOCUS_INPUT,
useCallback((event: KeyboardEvent) => {
// Only handle if we're not already in an input/textarea
const activeElement = document.activeElement;
const isTyping =
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement;
if (isTyping) {
// Don't intercept - let the "/" be typed normally for slash commands
return;
}
// Not typing anywhere, so focus the chat input
const inputHandle = chatInputRef.current;
if (inputHandle) {
event.preventDefault(); // Prevent the "/" from being typed when we focus
inputHandle.focusInput();
}
}, []),
{ enabled: true, preventDefault: false }
);
// Cancel agent turn with ESC shortcut
useKeyboardShortcut(
SHORTCUTS.CANCEL,
useCallback(() => {
if (isAgentBusy) {
handleCancelTurn();
}
}, [isAgentBusy, handleCancelTurn]),
{ enabled: isAgentBusy }
);
return (
<div className="flex flex-col h-full min-h-0">
@@ -207,6 +253,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
<div className="flex-shrink-0 flex flex-col gap-2 mt-2">
{todoItems.length > 0 && <TodoSummary todos={todoItems} />}
<ChatInputContainer
ref={chatInputRef}
key={clarificationKey}
onSubmit={handleSubmit}
sessionId={resolvedSessionId}
@@ -232,6 +279,8 @@ export const TaskChatPanel = memo(function TaskChatPanel({
onRemoveCommentFile={handleRemoveCommentFile}
onRemoveComment={handleRemoveComment}
onCommentClick={onOpenFileAtLine ? (comment) => onOpenFileAtLine(comment.filePath) : undefined}
submitKey={chatSubmitKey}
hasAgentCommands={hasAgentCommands}
/>
</div>
</div>
@@ -52,6 +52,7 @@ export function useEditors() {
shellOptions: data.shell_options ?? [],
defaultEditorId: data.settings.default_editor_id || null,
enablePreviewOnClick: data.settings.enable_preview_on_click ?? false,
chatSubmitKey: data.settings.chat_submit_key ?? 'cmd_enter',
loaded: true,
});
})
@@ -14,6 +14,7 @@ type DisplaySettings = {
shellOptions: Array<{ value: string; label: string }>;
defaultEditorId: string | null;
enablePreviewOnClick: boolean;
chatSubmitKey: 'enter' | 'cmd_enter';
loaded: boolean;
};
@@ -56,6 +57,7 @@ export function useUserDisplaySettings({
shellOptions: userSettings.shellOptions ?? [],
defaultEditorId: userSettings.defaultEditorId ?? null,
enablePreviewOnClick,
chatSubmitKey: userSettings.chatSubmitKey ?? 'cmd_enter',
loaded: true,
};
const sameWorkspace = normalized.workspaceId === userSettings.workspaceId;
@@ -105,6 +107,7 @@ export function useUserDisplaySettings({
shellOptions: data.shell_options ?? [],
defaultEditorId: data.settings.default_editor_id || null,
enablePreviewOnClick: data.settings.enable_preview_on_click ?? false,
chatSubmitKey: data.settings.chat_submit_key ?? 'cmd_enter',
loaded: true,
});
})
+4 -3
View File
@@ -23,12 +23,13 @@ export async function fetchUserSettings(options?: ApiRequestOptions) {
export async function updateUserSettings(
payload: {
workspace_id: string;
board_id: string;
repository_ids: string[];
workspace_id?: string;
board_id?: string;
repository_ids?: string[];
preferred_shell?: string;
default_editor_id?: string;
enable_preview_on_click?: boolean;
chat_submit_key?: 'enter' | 'cmd_enter';
},
options?: ApiRequestOptions
) {
+8
View File
@@ -60,6 +60,8 @@ export const KEYS = {
SEVEN: '7',
EIGHT: '8',
NINE: '9',
// Symbols
SLASH: '/',
} as const;
// Platform types
@@ -94,6 +96,9 @@ export const SHORTCUTS = {
key: KEYS.ENTER,
modifiers: { ctrlOrCmd: true },
},
SUBMIT_ENTER: {
key: KEYS.ENTER,
},
SAVE: {
key: KEYS.S,
modifiers: { ctrlOrCmd: true },
@@ -113,5 +118,8 @@ export const SHORTCUTS = {
key: KEYS.B,
modifiers: { ctrlOrCmd: true },
},
FOCUS_INPUT: {
key: KEYS.SLASH,
},
} as const;
@@ -26,6 +26,7 @@ export const defaultSettingsState: SettingsSliceState = {
shellOptions: [],
defaultEditorId: null,
enablePreviewOnClick: false,
chatSubmitKey: 'cmd_enter',
loaded: false,
},
};
@@ -76,6 +76,7 @@ export type UserSettingsState = {
shellOptions: Array<{ value: string; label: string }>;
defaultEditorId: string | null;
enablePreviewOnClick: boolean;
chatSubmitKey: 'enter' | 'cmd_enter';
loaded: boolean;
};
+1
View File
@@ -282,6 +282,7 @@ export type UserSettingsUpdatedPayload = {
preferred_shell?: string;
default_editor_id?: string;
enable_preview_on_click?: boolean;
chat_submit_key?: string;
updated_at?: string;
};
+1
View File
@@ -316,6 +316,7 @@ export type UserSettings = {
preferred_shell?: string;
default_editor_id?: string;
enable_preview_on_click?: boolean;
chat_submit_key?: 'enter' | 'cmd_enter';
updated_at: string;
};
+1
View File
@@ -16,6 +16,7 @@ export function registerUsersHandlers(store: StoreApi<AppState>): WsHandlers {
shellOptions: state.userSettings.shellOptions ?? [],
defaultEditorId: message.payload.default_editor_id || null,
enablePreviewOnClick: message.payload.enable_preview_on_click ?? false,
chatSubmitKey: (message.payload.chat_submit_key as 'enter' | 'cmd_enter') ?? 'cmd_enter',
loaded: true,
},
}));