From 7edb6891f37fcbf3f44d666490aee8126cf7bb0d Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:24:53 +0200 Subject: [PATCH 01/14] fix: Animate Code Composer Menus With Scale and a Shorter Fade Tailwind v4 scale utilities set the scale property, which transition-[opacity,transform] never animated, so the workspace, machine and approval menus snapped in size and then ran a 200ms fade that Ariakit also waits out before unmounting. Transition opacity and scale over 150ms to match the shared popover, and honor reduced motion in the approval menu. --- client/src/components/Chat/Input/CodeApprovalMenu.tsx | 2 +- client/src/components/Chat/Input/CodeWorkspaceMenu.tsx | 2 +- eslint-suppressions.json | 10 ---------- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/client/src/components/Chat/Input/CodeApprovalMenu.tsx b/client/src/components/Chat/Input/CodeApprovalMenu.tsx index e28e8c3fd15..342d588d5ef 100644 --- a/client/src/components/Chat/Input/CodeApprovalMenu.tsx +++ b/client/src/components/Chat/Input/CodeApprovalMenu.tsx @@ -135,7 +135,7 @@ export default function CodeApprovalMenu({ className={cn( 'z-50 flex max-w-[min(320px,calc(100vw-2rem))] min-w-[260px] flex-col rounded-2xl', 'border-border-menu bg-surface-menu max-h-[var(--popover-available-height)] overflow-y-auto border p-1 shadow-lg', - 'origin-bottom opacity-0 transition-[opacity,transform] duration-200 ease-out', + 'origin-bottom opacity-0 transition duration-150 ease-out motion-reduce:transition-none', 'data-[enter]:scale-100 data-[enter]:opacity-100', 'scale-95 data-[leave]:scale-95 data-[leave]:opacity-0', )} diff --git a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx index de06ca2008b..b8584da9598 100644 --- a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx +++ b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx @@ -53,7 +53,7 @@ const headingClasses = 'px-2.5 pt-2 pb-1 text-xs font-semibold text-text-seconda const menuClasses = cn( 'z-50 flex max-w-[min(360px,calc(100vw-2rem))] min-w-[260px] flex-col rounded-2xl', 'border-border-menu bg-surface-menu max-h-[var(--popover-available-height)] overflow-y-auto border p-1 shadow-lg', - 'origin-bottom opacity-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none', + 'origin-bottom opacity-0 transition duration-150 ease-out motion-reduce:transition-none', 'data-[enter]:scale-100 data-[enter]:opacity-100', 'scale-95 data-[leave]:scale-95 data-[leave]:opacity-0', ); diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 306e6909c11..5c660685049 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -170,16 +170,6 @@ "count": 2 } }, - "client/src/components/Chat/Input/CodeApprovalMenu.tsx": { - "shadcn/no-arbitrary-values": { - "count": 1 - } - }, - "client/src/components/Chat/Input/CodeWorkspaceMenu.tsx": { - "shadcn/no-arbitrary-values": { - "count": 1 - } - }, "client/src/components/Chat/Input/ConversationStarters.tsx": { "shadcn/no-inline-styles": { "count": 2 From 5a49810661f25bca62e239ac1737ebbffee6ed0f Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:54:33 +0200 Subject: [PATCH 02/14] fix: Align Agent Builder Instructions Header With Skills The Inline/Prompt toggle now uses the shared Radio at a new sm size, which the skills mode control also adopts instead of overriding its spacing. The variable and expand actions stay right-aligned when the editor label is visually hidden, and MCP rows in the tools list no longer show a tool count. --- .../SidePanel/Agents/Instructions.tsx | 49 +++++++++---------- .../SidePanel/Agents/Tools/SkillsSection.tsx | 2 +- .../SidePanel/Agents/Tools/ToolRow.tsx | 1 - .../Agents/__tests__/Instructions.spec.tsx | 24 ++++----- client/src/components/Variables/Editor.tsx | 3 +- eslint-suppressions.json | 3 -- packages/client/src/components/Radio.tsx | 10 +++- 7 files changed, 47 insertions(+), 45 deletions(-) diff --git a/client/src/components/SidePanel/Agents/Instructions.tsx b/client/src/components/SidePanel/Agents/Instructions.tsx index cb1d84c82de..cc5e35231a1 100644 --- a/client/src/components/SidePanel/Agents/Instructions.tsx +++ b/client/src/components/SidePanel/Agents/Instructions.tsx @@ -1,4 +1,5 @@ -import { Button, Label } from '@librechat/client'; +import { useId, useMemo } from 'react'; +import { Label, Radio } from '@librechat/client'; import { Controller, useWatch, useFormContext } from 'react-hook-form'; import type { AgentForm } from '~/common'; import InstructionsPromptFields, { fieldWrapperClass, LoadError } from './InstructionsPromptFields'; @@ -14,42 +15,38 @@ import { cn } from '~/utils'; * resolves, rather than briefly reading the link as absent. */ export type InstructionsPromptStatus = 'ready' | 'loading' | 'error'; -/** Two-way segmented toggle between the inline editor and a linked prompt group. */ +/** Two-way segmented toggle between the inline editor and a linked prompt group, matching the + * skills mode control so the section headers read as one set. */ function SourceToggle({ disabled = false }: { disabled?: boolean }) { const localize = useLocalize(); + const labelId = useId(); const { control } = useFormContext(); + const options = useMemo( + () => [ + { value: 'inline', label: localize('com_agents_instructions_source_inline') }, + { value: 'prompt', label: localize('com_agents_instructions_source_prompt') }, + ], + [localize], + ); return ( ( -
- - -
+ aria-labelledby={labelId} + /> + )} /> ); diff --git a/client/src/components/SidePanel/Agents/Tools/SkillsSection.tsx b/client/src/components/SidePanel/Agents/Tools/SkillsSection.tsx index cc8dc0d1eac..1a694066cbf 100644 --- a/client/src/components/SidePanel/Agents/Tools/SkillsSection.tsx +++ b/client/src/components/SidePanel/Agents/Tools/SkillsSection.tsx @@ -286,7 +286,7 @@ export default function SkillsSection({ items, onInfo, onRemove, onAdd }: Props) options={modeOptions} value={mode} onChange={handleModeChange} - buttonClassName="!h-7 !px-2.5 !text-xs" + size="sm" aria-labelledby="skills-mode-label" /> diff --git a/client/src/components/SidePanel/Agents/Tools/ToolRow.tsx b/client/src/components/SidePanel/Agents/Tools/ToolRow.tsx index 75ae6e367d4..cbcfa5a0a11 100644 --- a/client/src/components/SidePanel/Agents/Tools/ToolRow.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ToolRow.tsx @@ -15,7 +15,6 @@ interface Props { } function getSuffix(item: AgentItem): string | null { - if (item.kind === 'mcp' && item.toolCount > 0) return `· ${item.toolCount}`; if (item.kind === 'action' && item.endpointCount > 0) return `· ${item.endpointCount}`; return null; } diff --git a/client/src/components/SidePanel/Agents/__tests__/Instructions.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/Instructions.spec.tsx index 488ce9afbbb..0cae180830d 100644 --- a/client/src/components/SidePanel/Agents/__tests__/Instructions.spec.tsx +++ b/client/src/components/SidePanel/Agents/__tests__/Instructions.spec.tsx @@ -69,7 +69,7 @@ describe('Agent Instructions', () => { it('switching to Prompt mode hides the inline editor without unmounting it', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'com_agents_instructions_source_prompt' })); + fireEvent.click(screen.getByRole('radio', { name: 'com_agents_instructions_source_prompt' })); expect(screen.getByTestId('instructions-prompt-fields')).toBeInTheDocument(); expect(screen.getByTestId('instructions-inline-panel')).toHaveClass('hidden'); @@ -102,10 +102,10 @@ describe('Agent Instructions', () => { expect(screen.getByTestId('restricted-instructions-prompt')).toBeInTheDocument(); expect(screen.getByTestId('instructions-prompt-fields')).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'com_agents_instructions_source_inline' }), + screen.getByRole('radio', { name: 'com_agents_instructions_source_inline' }), ).not.toBeDisabled(); expect( - screen.getByRole('button', { name: 'com_agents_instructions_source_prompt' }), + screen.getByRole('radio', { name: 'com_agents_instructions_source_prompt' }), ).not.toBeDisabled(); }); @@ -117,7 +117,7 @@ describe('Agent Instructions', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: 'com_agents_instructions_source_inline' })); + fireEvent.click(screen.getByRole('radio', { name: 'com_agents_instructions_source_inline' })); expect(screen.queryByTestId('instructions-prompt-fields')).not.toBeInTheDocument(); expect(screen.queryByTestId('restricted-instructions-prompt')).not.toBeInTheDocument(); @@ -136,10 +136,10 @@ describe('Agent Instructions', () => { />, ); - const promptButton = screen.getByRole('button', { + const promptButton = screen.getByRole('radio', { name: 'com_agents_instructions_source_prompt', }); - const inlineButton = screen.getByRole('button', { + const inlineButton = screen.getByRole('radio', { name: 'com_agents_instructions_source_inline', }); @@ -168,10 +168,10 @@ describe('Agent Instructions', () => { expect(screen.queryByTestId('instructions-prompt-fields')).not.toBeInTheDocument(); expect(screen.queryByTestId('instructions-inline-panel')).not.toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'com_agents_instructions_source_inline' }), + screen.getByRole('radio', { name: 'com_agents_instructions_source_inline' }), ).toBeDisabled(); expect( - screen.getByRole('button', { name: 'com_agents_instructions_source_prompt' }), + screen.getByRole('radio', { name: 'com_agents_instructions_source_prompt' }), ).toBeDisabled(); }); @@ -191,7 +191,7 @@ describe('Agent Instructions', () => { expect(screen.getByTestId('instructions-prompt-fields')).toBeInTheDocument(); expect(screen.queryByText('com_ui_loading')).not.toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'com_agents_instructions_source_prompt' }), + screen.getByRole('radio', { name: 'com_agents_instructions_source_prompt' }), ).not.toBeDisabled(); }); @@ -215,10 +215,10 @@ describe('Agent Instructions', () => { 'com_agents_instructions_prompt_load_error', ); expect( - screen.getByRole('button', { name: 'com_agents_instructions_source_inline' }), + screen.getByRole('radio', { name: 'com_agents_instructions_source_inline' }), ).toBeDisabled(); expect( - screen.getByRole('button', { name: 'com_agents_instructions_source_prompt' }), + screen.getByRole('radio', { name: 'com_agents_instructions_source_prompt' }), ).toBeDisabled(); await user.click(screen.getByRole('button', { name: 'com_ui_retry' })); @@ -231,7 +231,7 @@ describe('Agent Instructions', () => { expect(screen.getByTestId('instructions-heading')).toHaveTextContent('com_ui_instructions'); expect( - screen.getByRole('group', { name: 'com_agents_instructions_source_toggle_aria' }), + screen.getByRole('radiogroup', { name: 'com_agents_instructions_source_toggle_aria' }), ).toBeInTheDocument(); }); diff --git a/client/src/components/Variables/Editor.tsx b/client/src/components/Variables/Editor.tsx index 7687925f8e2..2704aa8c10b 100644 --- a/client/src/components/Variables/Editor.tsx +++ b/client/src/components/Variables/Editor.tsx @@ -104,7 +104,8 @@ export default function VariableEditor({ -
+ {/* `ml-auto` keeps the actions right-aligned when the label is visually hidden. */} +
{showVariables && ( = memo(function Radio({ options, value, @@ -37,6 +44,7 @@ const Radio: React.NamedExoticComponent = memo(function Radio({ buttonClassName = '', fullWidth = false, wrap = false, + size = 'default', 'aria-labelledby': ariaLabelledBy, }: RadioProps) { const localize = useLocalize(); @@ -189,7 +197,7 @@ const Radio: React.NamedExoticComponent = memo(function Radio({ onClick={() => handleChange(option.value)} onKeyDown={(event) => handleKeyDown(event, index)} disabled={disabled} - className={`focus-visible:ring-focus-control relative z-10 flex h-[2.125rem] items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-hidden ${ + className={`focus-visible:ring-focus-control relative z-10 flex items-center justify-center gap-2 rounded-md font-medium ${segmentSizeClasses[size]} transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-hidden ${ currentValue === option.value ? 'text-text-primary' : 'text-text-secondary' } ${disabled ? 'cursor-not-allowed opacity-50' : ''} ${disabledInkClasses} ${fullWidth ? 'flex-1' : ''} ${buttonClassName}`} > From 72fd8febc6f0c718421c53a5345dc8eb5c71bac3 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:56:08 +0200 Subject: [PATCH 03/14] feat: Compact Code Workspace Menus and a Checkout Mode Picker Workspace rows are single lines with the repository and ref beside the name, and the approval, workspace and machine menus share one surface that rises in from its chip on the theme motion steps. The checkout chip becomes a menu offering Auto, Isolated worktree and Registered checkout, so the worker's automatic policy can be restored after an override; the normal workspace menu no longer repeats that choice. Approval and checkout descriptions are shortened. --- .../Chat/Input/CodeApprovalMenu.tsx | 27 +- .../Chat/Input/CodeWorkspaceMenu.tsx | 393 +++++++++--------- .../__tests__/CodeWorkspaceMenu.spec.tsx | 151 ++++--- client/src/components/Chat/Input/chip.ts | 22 + client/src/locales/en/translation.json | 16 +- 5 files changed, 326 insertions(+), 283 deletions(-) diff --git a/client/src/components/Chat/Input/CodeApprovalMenu.tsx b/client/src/components/Chat/Input/CodeApprovalMenu.tsx index 342d588d5ef..9ba1323fc89 100644 --- a/client/src/components/Chat/Input/CodeApprovalMenu.tsx +++ b/client/src/components/Chat/Input/CodeApprovalMenu.tsx @@ -7,9 +7,9 @@ import type { TConversation, CodeApprovalMode, CodeEnvironmentMode } from 'libre import type { LucideIcon } from 'lucide-react'; import type { SetterOrUpdater } from 'recoil'; import type { TranslationKeys } from '~/hooks'; +import { chipClasses, chipMenuClasses, chipMenuItemClasses, chipMenuHeadingClasses } from './chip'; import { useCodeApprovalModePreference } from '~/hooks/Agents/codeApprovalPreference'; import { useCodeApprovalMode, useLocalize } from '~/hooks'; -import { chipClasses } from './chip'; import { cn } from '~/utils'; /** The modes answer one question — what may happen to the workspace without the @@ -128,23 +128,9 @@ export default function CodeApprovalMenu({ aria-hidden="true" /> - + {/* Names the menu without adding an `h1` to the page outline. */} - } - className="text-text-secondary px-2.5 pt-2 pb-1 text-xs font-semibold" - > + } className={chipMenuHeadingClasses}> {localize('com_ui_code_approval_mode')} {modes.map((mode) => { @@ -158,12 +144,7 @@ export default function CodeApprovalMenu({ checked={isSelected} hideOnClick={true} onChange={() => selectMode(mode)} - className={cn( - 'group flex w-full cursor-pointer items-start gap-2.5 rounded-lg px-2.5 py-1.5', - 'duration-theme-fast outline-hidden transition-colors', - 'hover:bg-surface-hover data-[active-item]:bg-surface-hover', - isSelected && 'bg-surface-active-alt', - )} + className={cn(chipMenuItemClasses(isSelected), 'items-start')} >