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:
Nguyễn Ngô Thượng
2026-02-23 00:04:25 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent d0e4dc2704
commit 74fa09225a
8 changed files with 372 additions and 49 deletions
@@ -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"
+285 -39
View File
@@ -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,
+7
View File
@@ -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',
+13
View File
@@ -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',