feat(chat): per-session draft persistence + expandable input focus mode (#480)
* feat(session-folders): drag-to-folder DnD, sort by activity, and UX improvements - Add DraggableSessionRow wrapping each session row so the whole row is draggable; stopPropagation prevents outer group-reorder DnD from firing - Add DroppableFolderWrapper + SessionFolderDndScope (inner DndContext scoped per group) with closestCenter collision detection - DragOverlay matches exact width/height of dragged row so cursor stays aligned - Folder header highlights (ring + primary colour) when a session hovers over it during drag - + button on folder header opens a dropdown: 'New session' / 'New folder' - + button on each folder row creates a session scoped to that folder - Empty folders are no longer auto-deleted (removed .filter(sessionIds.length>0) from addSessionToFolder / removeSessionFromFolder / cleanupSessions) - Sessions inside a folder are sorted by most-recent activity (same compareSessionsByPinnedAndTime logic used everywhere else) - Sort comparator now takes sessionAttentionStates so lastUserMessageAt / lastStatusChangeAt is used when newer than session.time.updated; all sort call-sites and their useMemo/useCallback deps updated accordingly - Remove foldersMap from cleanup effect deps to prevent cascade re-renders when folders change; read current value via getState() instead * fix(session-folders): new session is placed into the correct folder sendMessage() was calling useSessionManagementStore.createSession() directly, bypassing the targetFolderId logic in useSessionStore.createSession. Fix: read targetFolderId from draft at the top of the draft branch in sendMessage, then call addSessionToFolder immediately after the session is created and before the draft is closed. Also propagate targetFolderId through openNewSessionDraft options and NewSessionDraftState type. * feat(session-folders): add sub-folder support (one level deep) - SessionFolder gains optional parentId field for hierarchy - createFolder accepts parentId to create sub-folders - deleteFolder cascades to remove all child sub-folders - SessionFolderItem renders sub-folders before sessions in body; new sub-folder button (RiFolderAddLine) visible at depth 0 only - renderOneFolderItem in SessionSidebar builds the tree recursively; sub-folders are indented via depth prop (ml-3 on root's children) - Persist/hydrate parentId correctly from localStorage * feat(session): add delete confirm dialogs and improve subtitle UX - Add confirmation dialogs before deleting sessions or folders - Show relative time (e.g., '2h ago', '35min ago') for recent sessions - Replace +/- diff numbers with file change count (e.g., '3 files changed') - New folders use default name without forcing rename - Cleaner, less cluttered session list UI * fix(session-folders): skip folder cleanup while sessions are loading Prevents race condition on reload where cleanupSessions() runs before the server returns the full session list, causing folder-session assignments to be incorrectly wiped from localStorage. * feat(chat): per-session draft persistence and expandable input focus mode - Feature 1 (#478): preserve chat draft per session when switching projects - Replace global localStorage key with per-session key (draft_${sessionId}) - Save draft for old session on switch, restore draft for new session - Clear draft on submit - Feature 2 (#479): expandable chat input focus mode (⌘⇧E / Ctrl+Shift+E) - Add isExpandedInput state to useUIStore - Register expand_input shortcut (mod+shift+e, customizable) - Wire shortcut in useKeyboardShortcuts - Overlay portal with full-height textarea, Esc to close, auto-close on submit - Expand button with tooltip showing keyboard shortcut hint * fix(chat): switch to in-place desktop focus mode, caret-anchored autocomplete, and no-focus-jump toggle --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d0e4dc2704
commit
74fa09225a
@@ -18,6 +18,7 @@ import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
import { TimelineDialog } from './TimelineDialog';
|
||||
import type { PermissionRequest } from '@/types/permission';
|
||||
import type { QuestionRequest } from '@/types/question';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
|
||||
@@ -101,6 +102,7 @@ export const ChatContainer: React.FC = () => {
|
||||
const {
|
||||
isTimelineDialogOpen,
|
||||
setTimelineDialogOpen,
|
||||
isExpandedInput,
|
||||
} = useUIStore();
|
||||
|
||||
const sessionMessages = useSessionStore(
|
||||
@@ -163,6 +165,7 @@ export const ChatContainer: React.FC = () => {
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const draftOpen = Boolean(newSessionDraft?.open);
|
||||
const isDesktopExpandedInput = isExpandedInput && !isMobile;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId && !draftOpen) {
|
||||
@@ -423,13 +426,22 @@ export const ChatContainer: React.FC = () => {
|
||||
if (!currentSessionId && draftOpen) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full bg-background transform-gpu"
|
||||
className="relative flex flex-col h-full bg-background transform-gpu"
|
||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||
>
|
||||
{!isDesktopExpandedInput ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<ChatEmptyState showDraftContext />
|
||||
</div>
|
||||
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
||||
)}
|
||||
>
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,13 +482,22 @@ export const ChatContainer: React.FC = () => {
|
||||
if (sessionMessages.length === 0 && !streamingMessageId) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full bg-background transform-gpu"
|
||||
className="relative flex flex-col h-full bg-background transform-gpu"
|
||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||
>
|
||||
{!isDesktopExpandedInput ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<ChatEmptyState />
|
||||
</div>
|
||||
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
||||
)}
|
||||
>
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,11 +506,18 @@ export const ChatContainer: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full bg-background"
|
||||
className="relative flex flex-col h-full bg-background"
|
||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||
>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
||||
: 'flex-1'
|
||||
)}
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<ScrollShadow
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||
@@ -518,8 +546,15 @@ export const ChatContainer: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
|
||||
{showScrollButton && sessionMessages.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
||||
)}
|
||||
>
|
||||
{!isDesktopExpandedInput && showScrollButton && sessionMessages.length > 0 && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
RiAttachment2,
|
||||
RiCommandLine,
|
||||
RiFileUploadLine,
|
||||
RiFullscreenLine,
|
||||
RiSendPlane2Line,
|
||||
} from '@remixicon/react';
|
||||
import { BrowserVoiceButton } from '@/components/voice';
|
||||
@@ -21,7 +22,7 @@ import { QueuedMessageChips } from './QueuedMessageChips';
|
||||
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
||||
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, isMacOS } from '@/lib/utils';
|
||||
import { ServerFilePicker } from './ServerFilePicker';
|
||||
import { ModelControls } from './ModelControls';
|
||||
import { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
|
||||
@@ -38,6 +39,7 @@ import { useMessageStore } from '@/stores/messageStore';
|
||||
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import { StopIcon } from '@/components/icons/StopIcon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { MobileControlsPanel } from './mobileControlsUtils';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -55,22 +57,49 @@ interface ChatInputProps {
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
}
|
||||
|
||||
const CHAT_INPUT_DRAFT_KEY = 'openchamber_chat_input_draft';
|
||||
type AutocompleteOverlayPosition = {
|
||||
top: number;
|
||||
left: number;
|
||||
place: 'above' | 'below';
|
||||
maxHeight: number;
|
||||
};
|
||||
|
||||
// Helper to safely read from localStorage
|
||||
const getStoredDraft = (): string => {
|
||||
// Per-session draft key — preserves in-progress messages across project switches
|
||||
const getDraftKey = (sessionId: string | null): string =>
|
||||
`openchamber_chat_input_draft_${sessionId ?? 'new'}`;
|
||||
|
||||
// Helper to safely read from localStorage for a given session
|
||||
const getStoredDraft = (sessionId: string | null): string => {
|
||||
try {
|
||||
return localStorage.getItem(CHAT_INPUT_DRAFT_KEY) ?? '';
|
||||
return localStorage.getItem(getDraftKey(sessionId)) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to safely write/clear a per-session draft
|
||||
const saveStoredDraft = (sessionId: string | null, draft: string): void => {
|
||||
try {
|
||||
if (draft) {
|
||||
localStorage.setItem(getDraftKey(sessionId), draft);
|
||||
} else {
|
||||
localStorage.removeItem(getDraftKey(sessionId));
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
};
|
||||
|
||||
export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
||||
// Track if we restored a draft on mount (for text selection)
|
||||
const initialDraftRef = React.useRef<string | null>(null);
|
||||
// Track initial session ID (captured at mount time for draft restoration)
|
||||
const initialSessionIdRef = React.useRef<string | null>(null);
|
||||
const [message, setMessage] = React.useState(() => {
|
||||
const draft = getStoredDraft();
|
||||
// Read per-session draft at mount time using the current session from the store
|
||||
const sessionId = useSessionStore.getState().currentSessionId;
|
||||
initialSessionIdRef.current = sessionId;
|
||||
const draft = getStoredDraft(sessionId);
|
||||
if (draft) {
|
||||
initialDraftRef.current = draft;
|
||||
}
|
||||
@@ -97,6 +126,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
|
||||
// Ref to track current message value without triggering re-renders in effects
|
||||
const messageRef = React.useRef(message);
|
||||
|
||||
const sendMessage = useSessionStore((state) => state.sendMessage);
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
@@ -118,10 +149,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
|
||||
const agents = getVisibleAgents();
|
||||
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
|
||||
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft } = useUIStore();
|
||||
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, isExpandedInput, setExpandedInput } = useUIStore();
|
||||
const { working } = useAssistantStatus();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||
const isDesktopExpanded = isExpandedInput && !isMobile;
|
||||
const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState<AutocompleteOverlayPosition | null>(null);
|
||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
|
||||
@@ -177,6 +210,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
.reverse(); // Most recent first
|
||||
}, [sessionMessages]);
|
||||
|
||||
// Keep messageRef in sync with message state
|
||||
React.useEffect(() => {
|
||||
messageRef.current = message;
|
||||
}, [message]);
|
||||
|
||||
// Handle initial draft restoration and text selection
|
||||
const hasHandledInitialDraftRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
@@ -190,7 +228,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
// Setting disabled - clear the restored draft
|
||||
setMessage('');
|
||||
try {
|
||||
localStorage.removeItem(CHAT_INPUT_DRAFT_KEY);
|
||||
localStorage.removeItem(getDraftKey(initialSessionIdRef.current));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
@@ -202,24 +240,31 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
}, [persistChatDraft]);
|
||||
|
||||
// Handle session switching: clear draft if persist disabled, select if enabled
|
||||
// Handle session switching: save draft for old session, restore draft for new session
|
||||
const prevSessionIdRef = React.useRef(currentSessionId);
|
||||
React.useEffect(() => {
|
||||
if (prevSessionIdRef.current !== currentSessionId) {
|
||||
const oldSessionId = prevSessionIdRef.current;
|
||||
prevSessionIdRef.current = currentSessionId;
|
||||
setInputMode('normal');
|
||||
|
||||
if (!persistChatDraft) {
|
||||
// Clear draft when switching sessions if persist is disabled
|
||||
|
||||
if (persistChatDraft) {
|
||||
// Save current draft for the session we're leaving
|
||||
saveStoredDraft(oldSessionId, messageRef.current);
|
||||
// Restore draft for the session we're entering
|
||||
const newDraft = getStoredDraft(currentSessionId);
|
||||
setMessage(newDraft);
|
||||
if (newDraft) {
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.select();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Persist disabled: clear input without saving
|
||||
setMessage('');
|
||||
} else if (message) {
|
||||
// Select text if there's any draft when switching sessions
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.select();
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [currentSessionId, persistChatDraft, message]);
|
||||
}, [currentSessionId, persistChatDraft]);
|
||||
|
||||
// Focus textarea when new session draft is opened
|
||||
const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen);
|
||||
@@ -238,27 +283,19 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
prevNewSessionDraftOpenRef.current = newSessionDraftOpen;
|
||||
}, [newSessionDraftOpen, isMobile]);
|
||||
|
||||
// Persist chat input draft to localStorage (only if setting enabled)
|
||||
// Persist chat input draft to localStorage per session (only if setting enabled)
|
||||
React.useEffect(() => {
|
||||
if (!persistChatDraft) {
|
||||
// Clear stored draft when setting is disabled
|
||||
// Clear stored draft for current session when setting is disabled
|
||||
try {
|
||||
localStorage.removeItem(CHAT_INPUT_DRAFT_KEY);
|
||||
localStorage.removeItem(getDraftKey(currentSessionId));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (message) {
|
||||
localStorage.setItem(CHAT_INPUT_DRAFT_KEY, message);
|
||||
} else {
|
||||
localStorage.removeItem(CHAT_INPUT_DRAFT_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, [message, persistChatDraft]);
|
||||
saveStoredDraft(currentSessionId, message);
|
||||
}, [message, persistChatDraft, currentSessionId]);
|
||||
|
||||
// Session activity for auto-send on idle
|
||||
const { phase: sessionPhase } = useCurrentSessionActivity();
|
||||
@@ -528,12 +565,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
if (!queuedOnly) {
|
||||
setMessage('');
|
||||
// Clear per-session draft on submit
|
||||
saveStoredDraft(currentSessionId, '');
|
||||
// Reset message history navigation state
|
||||
setHistoryIndex(-1);
|
||||
setDraftMessage('');
|
||||
if (attachedFiles.length > 0) {
|
||||
clearAttachedFiles();
|
||||
}
|
||||
// Close expanded input overlay when submitting
|
||||
setExpandedInput(false);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
@@ -728,6 +769,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
}
|
||||
|
||||
if (isDesktopExpanded && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setExpandedInput(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Tab' && !showCommandAutocomplete && !showFileMention) {
|
||||
e.preventDefault();
|
||||
handleCycleAgent();
|
||||
@@ -812,6 +859,127 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
};
|
||||
|
||||
const measureCaretInTextarea = React.useCallback((textarea: HTMLTextAreaElement, cursorPosition: number) => {
|
||||
const doc = textarea.ownerDocument;
|
||||
const win = doc.defaultView;
|
||||
if (!win) return null;
|
||||
|
||||
const style = win.getComputedStyle(textarea);
|
||||
const mirror = doc.createElement('div');
|
||||
const mirrorStyle = mirror.style;
|
||||
|
||||
mirrorStyle.position = 'absolute';
|
||||
mirrorStyle.visibility = 'hidden';
|
||||
mirrorStyle.pointerEvents = 'none';
|
||||
mirrorStyle.whiteSpace = 'pre-wrap';
|
||||
mirrorStyle.wordWrap = 'break-word';
|
||||
mirrorStyle.overflow = 'hidden';
|
||||
mirrorStyle.left = '-9999px';
|
||||
mirrorStyle.top = '0';
|
||||
|
||||
mirrorStyle.width = `${textarea.clientWidth}px`;
|
||||
mirrorStyle.font = style.font;
|
||||
mirrorStyle.fontSize = style.fontSize;
|
||||
mirrorStyle.fontFamily = style.fontFamily;
|
||||
mirrorStyle.fontWeight = style.fontWeight;
|
||||
mirrorStyle.fontStyle = style.fontStyle;
|
||||
mirrorStyle.fontVariant = style.fontVariant;
|
||||
mirrorStyle.letterSpacing = style.letterSpacing;
|
||||
mirrorStyle.textTransform = style.textTransform;
|
||||
mirrorStyle.textIndent = style.textIndent;
|
||||
mirrorStyle.padding = style.padding;
|
||||
mirrorStyle.border = style.border;
|
||||
mirrorStyle.boxSizing = style.boxSizing;
|
||||
mirrorStyle.lineHeight = style.lineHeight;
|
||||
mirrorStyle.tabSize = style.tabSize;
|
||||
|
||||
mirror.textContent = textarea.value.slice(0, cursorPosition);
|
||||
const marker = doc.createElement('span');
|
||||
marker.textContent = textarea.value.slice(cursorPosition, cursorPosition + 1) || ' ';
|
||||
mirror.appendChild(marker);
|
||||
|
||||
doc.body.appendChild(mirror);
|
||||
const top = marker.offsetTop;
|
||||
const left = marker.offsetLeft;
|
||||
doc.body.removeChild(mirror);
|
||||
|
||||
return { top, left };
|
||||
}, []);
|
||||
|
||||
const updateAutocompleteOverlayPosition = React.useCallback(() => {
|
||||
if (!isDesktopExpanded) {
|
||||
setAutocompleteOverlayPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showCommandAutocomplete && !showSkillAutocomplete && !showFileMention) {
|
||||
setAutocompleteOverlayPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
const container = dropZoneRef.current;
|
||||
if (!textarea || !container) return;
|
||||
|
||||
const cursor = textarea.selectionStart ?? message.length;
|
||||
const caret = measureCaretInTextarea(textarea, cursor);
|
||||
if (!caret) return;
|
||||
|
||||
const textareaRect = textarea.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
const caretY = textareaRect.top - containerRect.top + (caret.top - textarea.scrollTop);
|
||||
const caretX = textareaRect.left - containerRect.left + (caret.left - textarea.scrollLeft);
|
||||
|
||||
const popupMargin = 8;
|
||||
const estimatedPopupHeight = 260;
|
||||
const spaceAbove = caretY - popupMargin;
|
||||
const spaceBelow = containerRect.height - caretY - popupMargin;
|
||||
const place: 'above' | 'below' = spaceBelow >= estimatedPopupHeight || spaceBelow >= spaceAbove ? 'below' : 'above';
|
||||
|
||||
const desiredWidth = showFileMention ? 520 : showCommandAutocomplete ? 450 : 360;
|
||||
const clampedLeft = Math.max(
|
||||
popupMargin,
|
||||
Math.min(caretX - 24, containerRect.width - desiredWidth - popupMargin)
|
||||
);
|
||||
|
||||
const maxHeight = Math.max(120, Math.min(estimatedPopupHeight, place === 'below' ? spaceBelow : spaceAbove));
|
||||
|
||||
setAutocompleteOverlayPosition({
|
||||
top: place === 'below' ? caretY + 22 : caretY - 6,
|
||||
left: clampedLeft,
|
||||
place,
|
||||
maxHeight,
|
||||
});
|
||||
}, [
|
||||
isDesktopExpanded,
|
||||
measureCaretInTextarea,
|
||||
message.length,
|
||||
showCommandAutocomplete,
|
||||
showFileMention,
|
||||
showSkillAutocomplete,
|
||||
]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
updateAutocompleteOverlayPosition();
|
||||
}, [
|
||||
updateAutocompleteOverlayPosition,
|
||||
message,
|
||||
showCommandAutocomplete,
|
||||
showSkillAutocomplete,
|
||||
showFileMention,
|
||||
isDesktopExpanded,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDesktopExpanded) return;
|
||||
const onResize = () => updateAutocompleteOverlayPosition();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [isDesktopExpanded, updateAutocompleteOverlayPosition]);
|
||||
|
||||
const startAbortIndicator = React.useCallback(() => {
|
||||
if (abortTimeoutRef.current) {
|
||||
clearTimeout(abortTimeoutRef.current);
|
||||
@@ -853,6 +1021,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDesktopExpanded) {
|
||||
textarea.style.height = '100%';
|
||||
textarea.style.maxHeight = 'none';
|
||||
setTextareaSize(null);
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
const view = textarea.ownerDocument?.defaultView;
|
||||
@@ -879,7 +1054,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
return { height: nextHeight, maxHeight };
|
||||
});
|
||||
}, []);
|
||||
}, [isDesktopExpanded]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
@@ -1856,11 +2031,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
<>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); handlePrimaryAction(); }}
|
||||
className={cn(
|
||||
"relative pt-0 pb-4",
|
||||
isDesktopExpanded && 'flex h-full min-h-0 flex-col pt-4',
|
||||
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
|
||||
)}
|
||||
data-keyboard-avoid="true"
|
||||
@@ -1879,7 +2055,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
showAbortStatus={showAbortStatus}
|
||||
/>
|
||||
</div>
|
||||
<div className="chat-column relative overflow-visible">
|
||||
<div className={cn('chat-column relative overflow-visible', isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||
<AttachedFilesList />
|
||||
<QueuedMessageChips
|
||||
onEditMessage={(content) => {
|
||||
@@ -1908,6 +2084,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col relative overflow-visible",
|
||||
isDesktopExpanded && 'flex-1 min-h-0',
|
||||
"border border-border/80",
|
||||
"focus-within:ring-1",
|
||||
inputMode === 'shell'
|
||||
@@ -1953,6 +2130,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
activeTab={autocompleteTab}
|
||||
onTabSelect={handleAutocompleteTabSelect}
|
||||
onClose={() => setShowCommandAutocomplete(false)}
|
||||
style={isDesktopExpanded && autocompleteOverlayPosition
|
||||
? {
|
||||
left: `${autocompleteOverlayPosition.left}px`,
|
||||
top: `${autocompleteOverlayPosition.top}px`,
|
||||
bottom: 'auto',
|
||||
width: `min(450px, calc(100% - ${autocompleteOverlayPosition.left + 8}px))`,
|
||||
maxHeight: `${autocompleteOverlayPosition.maxHeight}px`,
|
||||
transform: autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined,
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
{ }
|
||||
@@ -1962,6 +2149,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
searchQuery={skillQuery}
|
||||
onSkillSelect={handleSkillSelect}
|
||||
onClose={() => setShowSkillAutocomplete(false)}
|
||||
style={isDesktopExpanded && autocompleteOverlayPosition
|
||||
? {
|
||||
left: `${autocompleteOverlayPosition.left}px`,
|
||||
top: `${autocompleteOverlayPosition.top}px`,
|
||||
bottom: 'auto',
|
||||
width: `min(360px, calc(100% - ${autocompleteOverlayPosition.left + 8}px))`,
|
||||
maxHeight: `${autocompleteOverlayPosition.maxHeight}px`,
|
||||
transform: autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined,
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1976,6 +2173,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
activeTab={autocompleteTab}
|
||||
onTabSelect={handleAutocompleteTabSelect}
|
||||
onClose={() => setShowFileMention(false)}
|
||||
style={isDesktopExpanded && autocompleteOverlayPosition
|
||||
? {
|
||||
left: `${autocompleteOverlayPosition.left}px`,
|
||||
top: `${autocompleteOverlayPosition.top}px`,
|
||||
bottom: 'auto',
|
||||
width: `min(520px, calc(100% - ${autocompleteOverlayPosition.left + 8}px))`,
|
||||
maxHeight: `${autocompleteOverlayPosition.maxHeight}px`,
|
||||
transform: autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined,
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
@@ -1989,6 +2196,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onPointerDownCapture={handleTextareaPointerDownCapture}
|
||||
onKeyUp={updateAutocompleteOverlayPosition}
|
||||
onClick={updateAutocompleteOverlayPosition}
|
||||
onScroll={updateAutocompleteOverlayPosition}
|
||||
onSelect={updateAutocompleteOverlayPosition}
|
||||
placeholder={currentSessionId || newSessionDraftOpen
|
||||
? inputMode === 'shell'
|
||||
? "Enter shell command..."
|
||||
@@ -1998,16 +2209,20 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
autoCorrect={isMobile ? "on" : "off"}
|
||||
autoCapitalize={isMobile ? "sentences" : "off"}
|
||||
spellCheck={isMobile}
|
||||
outerClassName="focus-within:ring-0"
|
||||
outerClassName={cn('focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
|
||||
className={cn(
|
||||
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent',
|
||||
isDesktopExpanded
|
||||
? 'h-full min-h-0 py-4'
|
||||
: isMobile
|
||||
? 'py-2.5'
|
||||
: 'pt-4 pb-2',
|
||||
inputMode === 'shell' && 'font-mono',
|
||||
isMobile ? "py-2.5" : "pt-4 pb-2"
|
||||
)}
|
||||
style={{
|
||||
flex: 'none',
|
||||
height: textareaSize ? `${textareaSize.height}px` : undefined,
|
||||
maxHeight: textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||
flex: isDesktopExpanded ? '1 1 auto' : 'none',
|
||||
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
|
||||
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
}}
|
||||
@@ -2060,6 +2275,36 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
<>
|
||||
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
|
||||
{attachmentsControls}
|
||||
<Tooltip delayDuration={600}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
footerIconButtonClass,
|
||||
'rounded-md',
|
||||
isExpandedInput
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:bg-[var(--interactive-hover)]/40 hover:text-foreground'
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onClick={() => setExpandedInput(!isExpandedInput)}
|
||||
aria-label="Toggle focus mode"
|
||||
aria-pressed={isExpandedInput}
|
||||
>
|
||||
<RiFullscreenLine className={cn(iconSizeClass)} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<div className="flex flex-col gap-0.5 text-center">
|
||||
<span>Focus mode</span>
|
||||
<span className="font-mono opacity-60">
|
||||
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
||||
<ModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
||||
@@ -2075,5 +2320,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ interface CommandAutocompleteProps {
|
||||
showTabs?: boolean;
|
||||
activeTab?: AutocompleteTab;
|
||||
onTabSelect?: (tab: AutocompleteTab) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, CommandAutocompleteProps>(({
|
||||
@@ -38,7 +39,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
onClose,
|
||||
showTabs,
|
||||
activeTab = 'commands',
|
||||
onTabSelect
|
||||
onTabSelect,
|
||||
style,
|
||||
}, ref) => {
|
||||
const { hasMessagesInCurrentSession, currentSessionId } = useSessionStore(
|
||||
useShallow((state) => {
|
||||
@@ -251,6 +253,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
|
||||
style={style}
|
||||
>
|
||||
{showTabs ? (
|
||||
<div className="px-2 pt-2 pb-1 border-b border-border/60">
|
||||
|
||||
@@ -32,6 +32,7 @@ interface FileMentionAutocompleteProps {
|
||||
showTabs?: boolean;
|
||||
activeTab?: AutocompleteTab;
|
||||
onTabSelect?: (tab: AutocompleteTab) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileMentionAutocompleteProps>(({
|
||||
@@ -42,6 +43,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
showTabs,
|
||||
activeTab = 'files',
|
||||
onTabSelect,
|
||||
style,
|
||||
}, ref) => {
|
||||
const { currentDirectory } = useDirectoryStore();
|
||||
const { addServerFile } = useSessionStore();
|
||||
@@ -367,6 +369,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[520px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
|
||||
style={style}
|
||||
>
|
||||
{showTabs ? (
|
||||
<div className="px-2 pt-2 pb-1 border-b border-border/60">
|
||||
|
||||
@@ -17,12 +17,14 @@ interface SkillAutocompleteProps {
|
||||
searchQuery: string;
|
||||
onSkillSelect: (skillName: string) => void;
|
||||
onClose: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, SkillAutocompleteProps>(({
|
||||
searchQuery,
|
||||
onSkillSelect,
|
||||
onClose,
|
||||
style,
|
||||
}, ref) => {
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
@@ -147,6 +149,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
|
||||
style={style}
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2" fillContainer={false}>
|
||||
{filteredSkills.length ? (
|
||||
|
||||
@@ -20,10 +20,12 @@ export const useKeyboardShortcuts = () => {
|
||||
setRightSidebarTab,
|
||||
toggleBottomTerminal,
|
||||
setBottomTerminalExpanded,
|
||||
isMobile,
|
||||
setSessionSwitcherOpen,
|
||||
setActiveMainTab,
|
||||
setSettingsDialogOpen,
|
||||
setModelSelectorOpen,
|
||||
toggleExpandedInput,
|
||||
shortcutOverrides,
|
||||
} = useUIStore();
|
||||
const { themeMode, setThemeMode } = useThemeSystem();
|
||||
@@ -286,6 +288,15 @@ export const useKeyboardShortcuts = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(e, combo('expand_input'))) {
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
toggleExpandedInput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
const target = e.target as Element | null;
|
||||
const isInsideDialog = Boolean(target?.closest('[role="dialog"]'));
|
||||
@@ -376,10 +387,12 @@ export const useKeyboardShortcuts = () => {
|
||||
setRightSidebarTab,
|
||||
toggleBottomTerminal,
|
||||
setBottomTerminalExpanded,
|
||||
isMobile,
|
||||
setSessionSwitcherOpen,
|
||||
setActiveMainTab,
|
||||
setSettingsDialogOpen,
|
||||
setModelSelectorOpen,
|
||||
toggleExpandedInput,
|
||||
setThemeMode,
|
||||
working,
|
||||
armAbortPrompt,
|
||||
|
||||
@@ -284,6 +284,13 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||
label: 'Cycle thinking variant',
|
||||
description: 'Cycle thinking variant while in chat',
|
||||
},
|
||||
{
|
||||
id: 'expand_input',
|
||||
defaultCombo: 'mod+shift+e',
|
||||
label: 'Expand input',
|
||||
description: 'Toggle focus mode for the chat input',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'abort_run',
|
||||
defaultCombo: 'escape',
|
||||
|
||||
@@ -215,6 +215,8 @@ interface UIStore {
|
||||
persistChatDraft: boolean;
|
||||
isMobileSessionStatusBarCollapsed: boolean;
|
||||
|
||||
isExpandedInput: boolean;
|
||||
|
||||
shortcutOverrides: Record<string, ShortcutCombo>;
|
||||
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||
@@ -298,6 +300,8 @@ interface UIStore {
|
||||
setMaxLastMessageLength: (value: number) => void;
|
||||
setPersistChatDraft: (value: boolean) => void;
|
||||
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
|
||||
toggleExpandedInput: () => void;
|
||||
setExpandedInput: (value: boolean) => void;
|
||||
openMultiRunLauncher: () => void;
|
||||
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
||||
setShortcutOverride: (actionId: string, combo: ShortcutCombo) => void;
|
||||
@@ -390,6 +394,7 @@ export const useUIStore = create<UIStore>()(
|
||||
showTerminalQuickKeysOnDesktop: false,
|
||||
persistChatDraft: true,
|
||||
isMobileSessionStatusBarCollapsed: false,
|
||||
isExpandedInput: false,
|
||||
shortcutOverrides: {},
|
||||
|
||||
setTheme: (theme) => {
|
||||
@@ -1131,6 +1136,14 @@ export const useUIStore = create<UIStore>()(
|
||||
resetAllShortcutOverrides: () => {
|
||||
set({ shortcutOverrides: {} });
|
||||
},
|
||||
|
||||
toggleExpandedInput: () => {
|
||||
set((state) => ({ isExpandedInput: !state.isExpandedInput }));
|
||||
},
|
||||
|
||||
setExpandedInput: (value) => {
|
||||
set({ isExpandedInput: value });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'ui-store',
|
||||
|
||||
Reference in New Issue
Block a user