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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user