perf: reduce UI render fanout and scroll jitter

- Cut broad render fanout across the app by replacing shared-store whole-object subscriptions with leaf selectors, memoizing hot chrome boundaries, and isolating disabled global providers from live session/message state. This keeps header controls, composer toolbars, side panels, and other non-hot UI surfaces from repainting on every assistant update or keystroke.

- Rework sidebar session ordering so recent, project groups, and worktree groups derive from one ordering source while avoiding streaming-time thrash. The sidebar now uses a stabilized session snapshot, preserves structural identity for unchanged rows, reads live row status/details per session, and applies a one-shot sort bump on idle->busy instead of continuously resorting during activity.

- Fix chat/input scroll instability by separating viewport-resize handling from message-growth handling, disabling conflicting native scroll anchoring, and stopping textarea autosize from collapsing on every growth keystroke. This removes the multiline typing jiggle during streaming and reduces unnecessary composer rerenders.

- Also gate voice context wiring behind voice-mode enablement and codify the learned render/scroll/order anti-patterns in AGENTS.md so future changes avoid the same classes of regressions.
This commit is contained in:
Bohdan Triapitsyn
2026-04-04 02:19:55 +03:00
parent c5497e9823
commit 8587db89b1
42 changed files with 1713 additions and 1047 deletions
@@ -45,8 +45,9 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const ignoreTabClickRef = React.useRef(false);
const { getVisibleAgents } = useConfigStore();
const { agents: agentsWithMetadata, loadAgents } = useAgentsStore();
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const agentsWithMetadata = useAgentsStore((state) => state.agents);
const loadAgents = useAgentsStore((state) => state.loadAgents);
React.useEffect(() => {
if (agentsWithMetadata.length === 0) {
@@ -104,7 +104,9 @@ export const ChatContainer: React.FC = () => {
);
// UI store
const { isExpandedInput, stickyUserHeader, chatRenderMode } = useUIStore();
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
// Streaming state
const streamingMessageId = useStreamingStore(
@@ -516,9 +518,7 @@ export const ChatContainer: React.FC = () => {
<ScrollShadow
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
ref={scrollRef}
style={(timelineController.pendingRevealWork || timelineController.isLoadingOlder)
? { overflowAnchor: 'none' }
: undefined}
style={{ overflowAnchor: 'none' }}
observeMutations={false}
hideTopShadow={isMobile && stickyUserHeader}
data-scroll-shadow="true"
+509 -292
View File
@@ -223,6 +223,370 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
return PROJECT_COLOR_MAP[projectColor] ?? undefined;
};
const MemoModelControls = React.memo(ModelControls);
const MemoUnifiedControlsDrawer = React.memo(UnifiedControlsDrawer);
const MemoBrowserVoiceButton = React.memo(BrowserVoiceButton);
const MemoMobileAgentButton = React.memo(MobileAgentButton);
const MemoMobileModelButton = React.memo(MobileModelButton);
const MemoStatusRow = React.memo(StatusRow);
type ComposerAttachmentControlsProps = {
isMobile: boolean;
isVSCode: boolean;
footerIconButtonClass: string;
iconSizeClass: string;
fileInputRef: React.RefObject<HTMLInputElement | null>;
handleLocalFileSelect: (event: React.ChangeEvent<HTMLInputElement>) => void | Promise<void>;
handlePickLocalFiles: () => void;
handleOpenCommandMenu: () => void;
openIssuePicker: () => void;
openPrPicker: () => void;
onOpenSettings?: () => void;
};
const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) {
const {
isMobile,
isVSCode,
footerIconButtonClass,
iconSizeClass,
fileInputRef,
handleLocalFileSelect,
handlePickLocalFiles,
handleOpenCommandMenu,
openIssuePicker,
openPrPicker,
onOpenSettings,
} = props;
return (
<div className="flex items-center gap-x-1.5">
{isMobile ? (
<button
type="button"
className={cn(
footerIconButtonClass,
'rounded-md',
'hover:bg-interactive-hover/40'
)}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') {
event.preventDefault();
event.stopPropagation();
}
}}
onClick={handleOpenCommandMenu}
title="Commands"
aria-label="Commands"
>
<RiCommandLine className={cn(iconSizeClass)} />
</button>
) : null}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleLocalFileSelect}
accept="*/*"
/>
<div className="relative inline-flex">
{isVSCode ? (
<button
type="button"
className={footerIconButtonClass}
onClick={handlePickLocalFiles}
title="Attach files"
aria-label="Attach files"
>
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={footerIconButtonClass}
title="Add attachment"
aria-label="Add attachment"
>
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(handlePickLocalFiles);
}}
>
<RiAttachment2 />
Attach files
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(openIssuePicker);
}}
>
<RiGithubLine />
Link GitHub Issue
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(openPrPicker);
}}
>
<RiGitPullRequestLine />
Link GitHub PR
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{onOpenSettings ? (
<button
type="button"
onClick={onOpenSettings}
className={footerIconButtonClass}
title="Model and agent settings"
aria-label="Model and agent settings"
>
<RiAiAgentLine className={cn(iconSizeClass, 'text-current')} />
</button>
) : null}
</div>
);
}, (prev, next) => (
prev.isMobile === next.isMobile
&& prev.isVSCode === next.isVSCode
&& prev.footerIconButtonClass === next.footerIconButtonClass
&& prev.iconSizeClass === next.iconSizeClass
&& prev.onOpenSettings === next.onOpenSettings
));
type PermissionAutoAcceptButtonProps = {
footerIconButtonClass: string;
iconSizeClass: string;
permissionScopeSessionId: string | null;
permissionAutoAcceptEnabled: boolean;
handlePermissionAutoAcceptToggle: () => void;
withTooltip?: boolean;
};
const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButton(props: PermissionAutoAcceptButtonProps) {
const {
footerIconButtonClass,
iconSizeClass,
permissionScopeSessionId,
permissionAutoAcceptEnabled,
handlePermissionAutoAcceptToggle,
withTooltip = false,
} = props;
const ariaLabel = permissionAutoAcceptEnabled
? 'Disable permission auto-accept'
: 'Enable permission auto-accept';
const tooltipLabel = permissionAutoAcceptEnabled
? 'Permission auto-accept: on'
: 'Permission auto-accept: off';
const button = (
<button
type="button"
onClick={handlePermissionAutoAcceptToggle}
className={cn(
footerIconButtonClass,
'rounded-md hover:bg-transparent',
!permissionScopeSessionId && 'opacity-30',
)}
onMouseDown={(event) => {
event.preventDefault();
}}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') {
event.preventDefault();
event.stopPropagation();
}
}}
aria-pressed={permissionAutoAcceptEnabled}
aria-label={ariaLabel}
title={ariaLabel}
>
{permissionAutoAcceptEnabled ? (
<RiShieldCheckLine className={cn(iconSizeClass)} style={{ color: 'var(--status-info)' }} />
) : (
<RiShieldUserLine className={cn(iconSizeClass)} />
)}
</button>
);
if (!withTooltip) {
return button;
}
return (
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
{button}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
{tooltipLabel}
</TooltipContent>
</Tooltip>
);
});
type FocusModeButtonProps = {
footerIconButtonClass: string;
iconSizeClass: string;
isExpandedInput: boolean;
onToggle: () => void;
};
const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
return (
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
footerIconButtonClass,
'rounded-md',
isExpandedInput
? 'text-primary'
: 'text-foreground hover:bg-[var(--interactive-hover)]/40'
)}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={onToggle}
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>
);
});
type ComposerActionButtonsProps = {
isMobile: boolean;
footerIconButtonClass: string;
sendIconSizeClass: string;
stopIconSizeClass: string;
canSend: boolean;
canAbort: boolean;
hasContent: boolean;
currentSessionId: string | null;
newSessionDraftOpen: boolean;
onPrimaryAction: () => void;
onQueueMessage: () => void;
onAbort: () => void;
};
const ComposerActionButtons = React.memo(function ComposerActionButtons(props: ComposerActionButtonsProps) {
const {
isMobile,
footerIconButtonClass,
sendIconSizeClass,
stopIconSizeClass,
canSend,
canAbort,
hasContent,
currentSessionId,
newSessionDraftOpen,
onPrimaryAction,
onQueueMessage,
onAbort,
} = props;
const sendButton = (
<button
type={isMobile ? 'button' : 'submit'}
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
onClick={(event) => {
if (!isMobile) {
return;
}
event.preventDefault();
onPrimaryAction();
}}
className={cn(
footerIconButtonClass,
canSend && (currentSessionId || newSessionDraftOpen)
? 'text-primary hover:text-primary'
: 'opacity-30'
)}
aria-label="Send message"
>
<RiSendPlane2Line className={cn(sendIconSizeClass)} />
</button>
);
if (!canAbort) {
return sendButton;
}
return (
<div className="relative">
{hasContent ? (
<button
type="button"
disabled={!currentSessionId}
onClick={(event) => {
if (isMobile) {
event.preventDefault();
}
onQueueMessage();
}}
className={cn(
footerIconButtonClass,
'absolute z-20 bottom-full left-1/2 -translate-x-1/2 mb-1',
currentSessionId ? 'text-primary hover:text-primary' : 'opacity-30'
)}
aria-label="Queue message"
>
<RiSendPlane2Line className={cn(sendIconSizeClass, '-rotate-90')} />
</button>
) : null}
<button
type="button"
onClick={onAbort}
className={cn(
footerIconButtonClass,
'text-[var(--status-error)] hover:text-[var(--status-error)]'
)}
aria-label="Stop generating"
>
<StopIcon className={cn(stopIconSizeClass)} />
</button>
</div>
);
}, (prev, next) => (
prev.isMobile === next.isMobile
&& prev.footerIconButtonClass === next.footerIconButtonClass
&& prev.sendIconSizeClass === next.sendIconSizeClass
&& prev.stopIconSizeClass === next.stopIconSizeClass
&& prev.canSend === next.canSend
&& prev.canAbort === next.canAbort
&& prev.hasContent === next.hasContent
&& prev.currentSessionId === next.currentSessionId
&& prev.newSessionDraftOpen === next.newSessionDraftOpen
));
const appendWithLineBreaks = (base: string, next: string): string => {
const separator = !base
? ''
@@ -322,6 +686,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent
const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const previousMessageLengthRef = React.useRef(message.length);
const dropZoneRef = React.useRef<HTMLDivElement>(null);
const suppressNextFileDropTextInsertRef = React.useRef(false);
const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -368,17 +733,29 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentVariant = useConfigStore((state) => state.currentVariant);
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const setAgent = useConfigStore((state) => state.setAgent);
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const agents = getVisibleAgents();
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
const { isMobile, inputBarOffset, isKeyboardOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore();
const isMobile = useUIStore((state) => state.isMobile);
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
const isKeyboardOpen = useUIStore((state) => state.isKeyboardOpen);
const cornerRadius = useUIStore((state) => state.cornerRadius);
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
const { working } = useAssistantStatus();
const { git: runtimeGit } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
const chatSearchDirectory = useChatSearchDirectory();
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
const [textareaScrollTop, setTextareaScrollTop] = React.useState(0);
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
const composerHighlightRef = React.useRef<HTMLDivElement | null>(null);
const isDesktopExpanded = isExpandedInput && !isMobile;
const chatInputRadius = 'var(--radius-lg)';
@@ -867,7 +1244,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
}, [pendingInputText, consumePendingInputText]);
const hasContent = message.trim() || sendableAttachedFiles.length > 0 || hasDrafts;
const hasContent = message.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts;
const hasQueuedMessages = queuedMessages.length > 0;
const canSend = hasContent || hasQueuedMessages;
@@ -913,6 +1290,29 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
}, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
const handleQueuedMessageEdit = React.useCallback((content: string) => {
setMessage(content);
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
}, []);
const handleOpenAgentPanel = React.useCallback(() => {
setMobileControlsPanel('agent');
}, []);
const handleToggleExpandedInput = React.useCallback(() => {
setExpandedInput(!isExpandedInput);
}, [isExpandedInput, setExpandedInput]);
const openIssuePicker = React.useCallback(() => {
setIssuePickerOpen(true);
}, []);
const openPrPicker = React.useCallback(() => {
setPrPickerOpen(true);
}, []);
const handleSubmit = async (options?: SubmitOptions) => {
const queuedOnly = options?.queuedOnly ?? false;
@@ -1509,20 +1909,27 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
}, [primaryAgents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]);
const adjustTextareaHeight = React.useCallback(() => {
const adjustTextareaHeight = React.useCallback((options?: { allowShrink?: boolean }) => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
const previousScrollTop = textarea.scrollTop;
if (isDesktopExpanded) {
textarea.style.height = '100%';
textarea.style.maxHeight = 'none';
setTextareaSize(null);
if (textarea.scrollTop !== previousScrollTop) {
textarea.scrollTop = previousScrollTop;
}
return;
}
textarea.style.height = 'auto';
if (options?.allowShrink ?? true) {
textarea.style.height = 'auto';
}
const view = textarea.ownerDocument?.defaultView;
const computedStyle = view ? view.getComputedStyle(textarea) : null;
@@ -1541,6 +1948,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
textarea.style.height = `${nextHeight}px`;
textarea.style.maxHeight = `${maxHeight}px`;
if (textarea.scrollTop !== previousScrollTop) {
textarea.scrollTop = previousScrollTop;
}
setTextareaSize((prev) => {
if (prev && prev.height === nextHeight && prev.maxHeight === maxHeight) {
@@ -1551,7 +1961,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}, [isDesktopExpanded]);
React.useLayoutEffect(() => {
adjustTextareaHeight();
const allowShrink = message.length < previousMessageLengthRef.current;
previousMessageLengthRef.current = message.length;
adjustTextareaHeight({ allowShrink });
}, [adjustTextareaHeight, message, isMobile]);
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
@@ -2723,238 +3135,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
});
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]);
const permissionAutoAcceptAriaLabel = permissionAutoAcceptEnabled
? 'Disable permission auto-accept'
: 'Enable permission auto-accept';
const permissionAutoAcceptTooltipLabel = permissionAutoAcceptEnabled
? 'Permission auto-accept: on'
: 'Permission auto-accept: off';
const permissionAutoAcceptButton = (
<button
type="button"
onClick={handlePermissionAutoAcceptToggle}
className={cn(
footerIconButtonClass,
'rounded-md hover:bg-transparent',
!permissionScopeSessionId && 'opacity-30',
)}
onMouseDown={(event) => {
event.preventDefault();
}}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') {
event.preventDefault();
event.stopPropagation();
}
}}
aria-pressed={permissionAutoAcceptEnabled}
aria-label={permissionAutoAcceptAriaLabel}
title={permissionAutoAcceptAriaLabel}
>
{permissionAutoAcceptEnabled ? (
<RiShieldCheckLine className={cn(iconSizeClass)} style={{ color: 'var(--status-info)' }} />
) : (
<RiShieldUserLine className={cn(iconSizeClass)} />
)}
</button>
);
const permissionAutoAcceptButtonWithTooltip = (
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
{permissionAutoAcceptButton}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
{permissionAutoAcceptTooltipLabel}
</TooltipContent>
</Tooltip>
);
// Send button - respects queue mode setting
const sendButton = (
<button
type={isMobile ? 'button' : 'submit'}
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
onClick={(event) => {
if (!isMobile) {
return;
}
event.preventDefault();
handlePrimaryAction();
}}
className={cn(
footerIconButtonClass,
canSend && (currentSessionId || newSessionDraftOpen)
? 'text-primary hover:text-primary'
: 'opacity-30'
)}
aria-label="Send message"
>
<RiSendPlane2Line className={cn(sendIconSizeClass)} />
</button>
);
// Queue button for adding message to queue while working
const queueButton = (
<button
type="button"
disabled={!hasContent || !currentSessionId}
onClick={(event) => {
if (isMobile) {
event.preventDefault();
}
handleQueueMessage();
}}
className={cn(
footerIconButtonClass,
'absolute z-20 bottom-full left-1/2 -translate-x-1/2 mb-1',
hasContent && currentSessionId
? 'text-primary hover:text-primary'
: 'opacity-30'
)}
aria-label="Queue message"
>
<RiSendPlane2Line className={cn(sendIconSizeClass, '-rotate-90')} />
</button>
);
// Stop button replaces send button when working
const stopButton = (
<button
type="button"
onClick={handleAbort}
className={cn(
footerIconButtonClass,
'text-[var(--status-error)] hover:text-[var(--status-error)]'
)}
aria-label="Stop generating"
>
<StopIcon className={cn(stopIconSizeClass)} />
</button>
);
// Action buttons area: either send button, or stop (+ optional queue button floating above)
const actionButtons = canAbort ? (
<div className="relative">
{hasContent && queueButton}
{stopButton}
</div>
) : (
sendButton
);
const attachmentMenu = (
<>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleLocalFileSelect}
accept="*/*"
/>
<div className="relative inline-flex">
{isVSCode ? (
<button
type="button"
className={footerIconButtonClass}
onClick={() => handlePickLocalFiles()}
title="Attach files"
aria-label="Attach files"
>
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={footerIconButtonClass}
title="Add attachment"
aria-label="Add attachment"
>
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(() => handlePickLocalFiles());
}}
>
<RiAttachment2 />
Attach files
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(() => {
setIssuePickerOpen(true);
});
}}
>
<RiGithubLine />
Link GitHub Issue
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(() => {
setPrPickerOpen(true);
});
}}
>
<RiGitPullRequestLine />
Link GitHub PR
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</>
);
const settingsButton = onOpenSettings ? (
<button
type='button'
onClick={onOpenSettings}
className={footerIconButtonClass}
title='Model and agent settings'
aria-label='Model and agent settings'
>
<RiAiAgentLine className={cn(iconSizeClass, 'text-current')} />
</button>
) : null;
const attachmentsControls = (
<div className="flex items-center gap-x-1.5">
{isMobile ? (
<button
type="button"
className={cn(
footerIconButtonClass,
'rounded-md',
'hover:bg-interactive-hover/40'
)}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') {
event.preventDefault();
event.stopPropagation();
}
}}
onClick={handleOpenCommandMenu}
title="Commands"
aria-label="Commands"
>
<RiCommandLine className={cn(iconSizeClass)} />
</button>
) : null}
{attachmentMenu}
{settingsButton}
</div>
);
const workingStatusText = working.statusText;
React.useEffect(() => {
@@ -2998,12 +3178,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
<div className={cn('chat-column relative overflow-visible', isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
<AttachedFilesList />
<QueuedMessageChips
onEditMessage={(content) => {
setMessage(content);
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
}}
onEditMessage={handleQueuedMessageEdit}
/>
{hasDrafts && (
<div className="pb-2">
@@ -3122,7 +3297,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
</button>
</div>
)}
<StatusRow
<MemoStatusRow
isWorking={working.isWorking}
statusText={workingStatusText}
isGenericStatus={working.isGenericStatus}
@@ -3326,7 +3501,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
: 'pt-4 pb-2',
inputMode === 'shell' ? 'font-mono' : 'typography-markdown md:typography-ui-label',
)}
style={{ transform: `translateY(-${textareaScrollTop}px)` }}
ref={composerHighlightRef}
>
{highlightedComposerContent.map((part, index) => (
<span
@@ -3361,7 +3536,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
onClick={updateAutocompleteOverlayPosition}
onScroll={(event) => {
updateAutocompleteOverlayPosition();
setTextareaScrollTop(event.currentTarget.scrollTop);
const scrollTop = event.currentTarget.scrollTop;
if (composerHighlightRef.current) {
composerHighlightRef.current.style.transform = `translateY(-${scrollTop}px)`;
}
}}
onSelect={updateAutocompleteOverlayPosition}
placeholder={currentSessionId || newSessionDraftOpen
@@ -3411,32 +3589,63 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
<>
<div className="flex w-full items-center justify-between gap-x-1.5">
<div className="flex items-center gap-x-1.5">
{attachmentsControls}
{permissionAutoAcceptButton}
<ComposerAttachmentControls
isMobile={isMobile}
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
fileInputRef={fileInputRef}
handleLocalFileSelect={handleLocalFileSelect}
handlePickLocalFiles={handlePickLocalFiles}
handleOpenCommandMenu={handleOpenCommandMenu}
openIssuePicker={openIssuePicker}
openPrPicker={openPrPicker}
onOpenSettings={onOpenSettings}
/>
<PermissionAutoAcceptButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
permissionScopeSessionId={permissionScopeSessionId}
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
handlePermissionAutoAcceptToggle={handlePermissionAutoAcceptToggle}
/>
</div>
<div className="flex items-center min-w-0 gap-x-1 justify-end">
<div className="flex items-center gap-x-1 min-w-0 max-w-[60vw] flex-shrink">
<MobileModelButton onOpenModel={handleOpenMobileControls} className="min-w-0 flex-shrink" />
<MobileAgentButton
onOpenAgentPanel={() => setMobileControlsPanel('agent')}
<MemoMobileModelButton onOpenModel={handleOpenMobileControls} className="min-w-0 flex-shrink" />
<MemoMobileAgentButton
onOpenAgentPanel={handleOpenAgentPanel}
onCycleAgent={handleCycleAgent}
className="min-w-0 flex-shrink"
/>
</div>
<div className="flex items-center gap-x-1 flex-shrink-0">
<BrowserVoiceButton />
{actionButtons}
<MemoBrowserVoiceButton />
<ComposerActionButtons
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
sendIconSizeClass={sendIconSizeClass}
stopIconSizeClass={stopIconSizeClass}
canSend={canSend}
canAbort={canAbort}
hasContent={!!hasContent}
currentSessionId={currentSessionId}
newSessionDraftOpen={newSessionDraftOpen}
onPrimaryAction={handlePrimaryAction}
onQueueMessage={handleQueueMessage}
onAbort={handleAbort}
/>
</div>
</div>
</div>
<ModelControls
<MemoModelControls
className="hidden"
mobilePanel={mobileControlsPanel}
onMobilePanelChange={setMobileControlsPanel}
onMobilePanelSelection={handleReturnToUnifiedControls}
onAgentPanelSelection={() => setMobileControlsPanel(null)}
/>
<UnifiedControlsDrawer
<MemoUnifiedControlsDrawer
open={mobileControlsOpen}
onClose={handleCloseMobileControls}
onOpenModel={() => handleOpenMobilePanel('model')}
@@ -3446,43 +3655,51 @@ 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-foreground hover:bg-[var(--interactive-hover)]/40'
)}
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>
{permissionAutoAcceptButtonWithTooltip}
<ComposerAttachmentControls
isMobile={isMobile}
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
fileInputRef={fileInputRef}
handleLocalFileSelect={handleLocalFileSelect}
handlePickLocalFiles={handlePickLocalFiles}
handleOpenCommandMenu={handleOpenCommandMenu}
openIssuePicker={openIssuePicker}
openPrPicker={openPrPicker}
onOpenSettings={onOpenSettings}
/>
<FocusModeButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
isExpandedInput={isExpandedInput}
onToggle={handleToggleExpandedInput}
/>
<PermissionAutoAcceptButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
permissionScopeSessionId={permissionScopeSessionId}
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
handlePermissionAutoAcceptToggle={handlePermissionAutoAcceptToggle}
withTooltip
/>
</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')} />
<BrowserVoiceButton />
{actionButtons}
<MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />
<MemoBrowserVoiceButton />
<ComposerActionButtons
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
sendIconSizeClass={sendIconSizeClass}
stopIconSizeClass={stopIconSizeClass}
canSend={canSend}
canAbort={canAbort}
hasContent={!!hasContent}
currentSessionId={currentSessionId}
newSessionDraftOpen={newSessionDraftOpen}
onPrimaryAction={handlePrimaryAction}
onQueueMessage={handleQueueMessage}
onAbort={handleAbort}
/>
</div>
</>
)}
@@ -14,8 +14,8 @@ import type { ToolPopupContent } from './message/types';
export const FileAttachmentButton = memo(() => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { addAttachedFile } = useInputStore();
const { isMobile } = useUIStore();
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
const isMobile = useUIStore((state) => state.isMobile);
const isVSCodeRuntime = useIsVSCodeRuntime();
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
@@ -256,7 +256,8 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
FileChip.displayName = 'FileChip';
export const AttachedFilesList = memo(() => {
const { attachedFiles, removeAttachedFile } = useInputStore();
const attachedFiles = useInputStore((state) => state.attachedFiles);
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
const localFiles = attachedFiles.filter((file) => file.source !== 'server');
@@ -64,7 +64,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
[projectRoot],
),
);
const { getVisibleAgents } = useConfigStore();
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const debouncedQuery = useDebouncedValue(searchQuery, 180);
const showHidden = useDirectoryShowHidden();
@@ -16,7 +16,8 @@ const LONG_PRESS_MS = 500;
// NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAgent, onOpenAgentPanel, className }) => {
const { currentAgentName, getVisibleAgents } = useConfigStore();
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionAgentName = useSelectionStore((state) =>
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
@@ -9,7 +9,8 @@ interface MobileModelButtonProps {
}
export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenModel, className }) => {
const { currentModelId, getCurrentProvider } = useConfigStore();
const currentModelId = useConfigStore((state) => state.currentModelId);
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
const currentProvider = getCurrentProvider();
const modelLabel = getModelDisplayName(currentProvider, currentModelId);
@@ -1438,8 +1438,11 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
const agents = useConfigStore((state) => state.agents);
const { getCurrentModel } = useConfigStore();
const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore();
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
const isMobile = useUIStore((state) => state.isMobile);
const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar);
const isMobileSessionStatusBarCollapsed = useUIStore((state) => state.isMobileSessionStatusBarCollapsed);
const setIsMobileSessionStatusBarCollapsed = useUIStore((state) => state.setIsMobileSessionStatusBarCollapsed);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
// Project store
@@ -293,25 +293,23 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
onMobilePanelSelection,
onAgentPanelSelection,
}) => {
const {
providers,
currentProviderId,
currentModelId,
currentVariant,
currentAgentName,
settingsDefaultVariant,
settingsDefaultAgent,
setProvider,
setSelectedProvider,
setModel,
setCurrentVariant,
getCurrentModelVariants,
setAgent,
getCurrentProvider,
getModelMetadata,
getCurrentAgent,
getVisibleAgents,
} = useConfigStore();
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentVariant = useConfigStore((state) => state.currentVariant);
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
const setProvider = useConfigStore((state) => state.setProvider);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const setModel = useConfigStore((state) => state.setModel);
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
const setAgent = useConfigStore((state) => state.setAgent);
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
const getCurrentAgent = useConfigStore((state) => state.getCurrentAgent);
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
// Use visible agents (excludes hidden internal agents)
const agents = getVisibleAgents();
@@ -321,15 +319,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession);
const sync = useSync();
const {
getSessionModelSelection,
saveSessionModelSelection,
saveSessionAgentSelection,
saveAgentModelForSession,
getAgentModelForSession,
saveAgentModelVariantForSession,
getAgentModelVariantForSession,
} = useSelectionStore();
const getSessionModelSelection = useSelectionStore((state) => state.getSessionModelSelection);
const saveSessionModelSelection = useSelectionStore((state) => state.saveSessionModelSelection);
const saveSessionAgentSelection = useSelectionStore((state) => state.saveSessionAgentSelection);
const saveAgentModelForSession = useSelectionStore((state) => state.saveAgentModelForSession);
const getAgentModelForSession = useSelectionStore((state) => state.getAgentModelForSession);
const saveAgentModelVariantForSession = useSelectionStore((state) => state.saveAgentModelVariantForSession);
const getAgentModelVariantForSession = useSelectionStore((state) => state.getAgentModelVariantForSession);
const contextHydrated = useContextStore((state) => state.hasHydrated);
@@ -355,19 +351,17 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
? (sessionSavedAgentName || stickySessionAgentName || currentAgentName)
: currentAgentName;
const {
toggleFavoriteModel,
isFavoriteModel,
collapsedModelProviders,
toggleModelProviderCollapsed,
addRecentModel,
addRecentAgent,
addRecentEffort,
isModelSelectorOpen,
setModelSelectorOpen,
setSettingsDialogOpen,
setSettingsPage,
} = useUIStore();
const toggleFavoriteModel = useUIStore((state) => state.toggleFavoriteModel);
const isFavoriteModel = useUIStore((state) => state.isFavoriteModel);
const collapsedModelProviders = useUIStore((state) => state.collapsedModelProviders);
const toggleModelProviderCollapsed = useUIStore((state) => state.toggleModelProviderCollapsed);
const addRecentModel = useUIStore((state) => state.addRecentModel);
const addRecentAgent = useUIStore((state) => state.addRecentAgent);
const addRecentEffort = useUIStore((state) => state.addRecentEffort);
const isModelSelectorOpen = useUIStore((state) => state.isModelSelectorOpen);
const setModelSelectorOpen = useUIStore((state) => state.setModelSelectorOpen);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const hiddenModels = useUIStore((state) => state.hiddenModels);
const collapsedProviderSet = React.useMemo(
() => new Set(collapsedModelProviders.map((providerId) => providerId.trim()).filter(Boolean)),
@@ -11,14 +11,12 @@ interface StatusChipProps {
}
export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) => {
const {
currentModelId,
currentVariant,
currentAgentName,
getCurrentProvider,
getCurrentModelVariants,
getVisibleAgents,
} = useConfigStore();
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentVariant = useConfigStore((state) => state.currentVariant);
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionAgentName = useContextStore((state) =>
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
@@ -158,7 +158,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
() => (currentSessionId ? todosRecord[currentSessionId] ?? EMPTY_TODOS : EMPTY_TODOS),
[todosRecord, currentSessionId],
);
const { isMobile } = useUIStore();
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
// Filter out cancelled todos for display and keep original order.
@@ -43,21 +43,22 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
onOpenModel,
onOpenEffort,
}) => {
const {
providers,
currentProviderId,
currentModelId,
currentVariant,
setProvider,
setModel,
setCurrentVariant,
getCurrentModelVariants,
getModelMetadata,
} = useConfigStore();
const { addRecentModel, addRecentEffort, recentEfforts } = useUIStore();
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentVariant = useConfigStore((state) => state.currentVariant);
const setProvider = useConfigStore((state) => state.setProvider);
const setModel = useConfigStore((state) => state.setModel);
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
const addRecentModel = useUIStore((state) => state.addRecentModel);
const addRecentEffort = useUIStore((state) => state.addRecentEffort);
const recentEfforts = useUIStore((state) => state.recentEfforts);
const { recentModelsList } = useModelLists();
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const { saveAgentModelForSession, saveAgentModelVariantForSession } = useSelectionStore();
const saveAgentModelForSession = useSelectionStore((state) => state.saveAgentModelForSession);
const saveAgentModelVariantForSession = useSelectionStore((state) => state.saveAgentModelVariantForSession);
const sessionAgentName = useContextStore((state) =>
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
);