From f24f62d0904048da7e60e89faa85c2e81a570acd Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:57:19 +0200 Subject: [PATCH] feat: fold rare upload destinations behind a disclosure Only the provider row stays out. Text, file search, code environment and the SharePoint mirrors sit behind one row that opens in place. Searching still reaches all of them by name. --- .../Chat/Input/Composer/Palette.tsx | 83 +++++++++++++++++-- client/src/hooks/Input/useAttachItems.tsx | 10 ++- client/src/locales/en/translation.json | 2 + 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/client/src/components/Chat/Input/Composer/Palette.tsx b/client/src/components/Chat/Input/Composer/Palette.tsx index 3da7075cf3..f527bd55b6 100644 --- a/client/src/components/Chat/Input/Composer/Palette.tsx +++ b/client/src/components/Chat/Input/Composer/Palette.tsx @@ -9,9 +9,9 @@ import React, { } from 'react'; import * as Ariakit from '@ariakit/react'; import { useSetRecoilState } from 'recoil'; -import { Star, Plus, Search } from 'lucide-react'; import { AutoSizer, List } from 'react-virtualized'; import { FileUpload, TooltipAnchor } from '@librechat/client'; +import { Star, Plus, Search, ChevronDown } from 'lucide-react'; import type { TFile, TConversation, @@ -60,6 +60,7 @@ const SECTION_LABEL: Record = { type PaletteRow = | { type: 'header'; key: string; label: string } | { type: 'attach'; key: string; entry: AttachEntry } + | { type: 'more'; key: string; label: string } | { type: 'file'; key: string; file: TFile } | { type: 'entry'; key: string; entry: PaletteEntry; isFavorite: boolean }; @@ -176,6 +177,10 @@ function Palette({ const open = popover.useState('open'); const mounted = popover.useState('mounted'); const [search, setSearch] = useState(''); + /* Kept across openings rather than reset with the search: someone who uploads + to a tool once will do it again, and re-expanding every time is the cost of + hiding it. */ + const [showAllAttach, setShowAllAttach] = useState(false); const [activeIndex, setActiveIndex] = useState(0); const inputRef = useRef(null); const disclosureRef = useRef(null); @@ -294,15 +299,31 @@ function Palette({ } if (canAttach) { + /* Searching reaches every destination: the disclosure is there to keep the + resting list short, not to make a row unfindable by name. */ const matched = query === '' - ? attach.entries + ? attach.entries.filter((item) => showAllAttach || item.primary === true) : attach.entries.filter((item) => item.label.toLowerCase().includes(query)); - if (matched.length > 0) { + const folded = query === '' && attach.entries.some((item) => item.primary !== true); + if (matched.length > 0 || folded) { next.push({ type: 'header', key: 'h:attach', label: localize('com_ui_composer_attach') }); for (const entry of matched) { next.push({ type: 'attach', key: entry.id, entry }); } + if (folded) { + next.push({ + type: 'more', + key: 'attach:more', + /* The label carries the state rather than `aria-expanded`, which an + `option` may not have: the word is what a screen reader reads and + what a sighted user sees, so neither has to infer it from the + chevron alone. */ + label: showAllAttach + ? localize('com_ui_composer_attach_less') + : localize('com_ui_composer_attach_more'), + }); + } } } @@ -339,7 +360,16 @@ function Palette({ } return next; - }, [entries, favorites.keys, query, attach.entries, recent.files, canAttach, localize]); + }, [ + entries, + favorites.keys, + query, + attach.entries, + showAllAttach, + recent.files, + canAttach, + localize, + ]); const totalHeight = useMemo(() => { let height = 0; @@ -353,10 +383,16 @@ function Palette({ /** Keep the highlight on a real row as the query narrows the list. */ const [lastRowsKey, setLastRowsKey] = useState(''); + const [keepKey, setKeepKey] = useState(''); const rowsKey = `${rows.length}:${rows[0]?.key ?? ''}`; if (rowsKey !== lastRowsKey) { setLastRowsKey(rowsKey); - setActiveIndex(firstSelectable === -1 ? 0 : firstSelectable); + const kept = keepKey === '' ? -1 : rows.findIndex((row) => row.key === keepKey); + const fallback = firstSelectable === -1 ? 0 : firstSelectable; + setActiveIndex(kept === -1 ? fallback : kept); + if (keepKey !== '') { + setKeepKey(''); + } } /* Cleared on unmount rather than on close: the popup stays up through its @@ -400,6 +436,14 @@ function Palette({ row.entry.onSelect(); return; } + /* Reveals the rest of the section in place, so the palette stays open. + The disclosure slides down past the rows it just revealed, so the + highlight is asked to follow it rather than snap back to the top. */ + if (row.type === 'more') { + setShowAllAttach((shown) => !shown); + setKeepKey(row.key); + return; + } /* Terminal like an upload destination: the file lands in the tray, which the open palette is covering. */ if (row.type === 'file') { @@ -477,6 +521,33 @@ function Palette({ const isActive = index === activeIndex; + if (row.type === 'more') { + return ( +
activate(index)} + onMouseEnter={() => setActiveIndex(index)} + className={cn( + 'flex cursor-pointer items-center gap-2.5 rounded-lg px-2 text-sm text-text-secondary', + isActive && 'bg-surface-hover', + )} + > +
+ ); + } + if (row.type === 'file') { const { file } = row; return ( @@ -619,7 +690,7 @@ function Palette({ ); }, - [rows, activeIndex, activate, favorites, localize], + [rows, activeIndex, activate, favorites, localize, showAllAttach], ); return ( diff --git a/client/src/hooks/Input/useAttachItems.tsx b/client/src/hooks/Input/useAttachItems.tsx index 78dfc787f3..f604ccdff0 100644 --- a/client/src/hooks/Input/useAttachItems.tsx +++ b/client/src/hooks/Input/useAttachItems.tsx @@ -59,6 +59,9 @@ export interface AttachEntry { id: string; label: string; icon: React.ReactNode; + /** Sending the file to the model, which is what nearly every upload means. + * The rest go to a tool and are folded away behind a disclosure. */ + primary?: boolean; onSelect: () => void; } @@ -192,6 +195,7 @@ export default function useAttachItems({ items.push({ id: `${prefix}:provider`, label: localize('com_ui_upload_provider'), + primary: true, icon: