feat: improved chat input keybinding (#173)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user