diff --git a/client/src/components/Chat/Steering/QueuedTurnPortal.tsx b/client/src/components/Chat/Steering/QueuedTurnPortal.tsx new file mode 100644 index 00000000000..d1631565842 --- /dev/null +++ b/client/src/components/Chat/Steering/QueuedTurnPortal.tsx @@ -0,0 +1,35 @@ +import { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; + +type QueuedTurnTarget = { + conversationId: string; + clientRequestId: string; + element: HTMLSpanElement; +}; + +type QueuedTurnPortalContextValue = { + target: QueuedTurnTarget | null; + setTarget: (target: QueuedTurnTarget | null) => void; +}; + +const QueuedTurnPortalContext = createContext(null); + +/** The composer owns queue actions; the transcript only supplies their mount point. */ +export function QueuedTurnPortalProvider({ children }: { children: ReactNode }) { + const [target, setTargetState] = useState(null); + const setTarget = useCallback((next: QueuedTurnTarget | null) => { + setTargetState((current) => + current?.element === next?.element && + current?.conversationId === next?.conversationId && + current?.clientRequestId === next?.clientRequestId + ? current + : next, + ); + }, []); + const value = useMemo(() => ({ target, setTarget }), [target, setTarget]); + return ( + {children} + ); +} + +export const useQueuedTurnPortal = () => useContext(QueuedTurnPortalContext); diff --git a/client/src/components/Chat/Steering/recovery.spec.ts b/client/src/components/Chat/Steering/recovery.spec.ts new file mode 100644 index 00000000000..d9783ec56af --- /dev/null +++ b/client/src/components/Chat/Steering/recovery.spec.ts @@ -0,0 +1,66 @@ +import { createStore } from 'jotai'; +import { + blockRecovery, + canRestoreRecovery, + recoveryDisposition, + recoveryDispositionsFamily, +} from './recovery'; + +const item = { recoverySteerId: 'source' }; + +describe('steer recovery safety state', () => { + beforeEach(() => { + sessionStorage.clear(); + recoveryDispositionsFamily.remove('conversation'); + }); + + it('preserves terminal cancellation and dismissal over a late start rejection', () => { + expect(blockRecovery({ source: 'cancelled' }, 'source')).toEqual({ source: 'cancelled' }); + expect(blockRecovery({ source: 'dismissed' }, 'source')).toEqual({ source: 'dismissed' }); + expect(blockRecovery({ source: 'cancelling' }, 'source')).toEqual({ source: 'cancelling' }); + expect(blockRecovery({}, 'source')).toEqual({ source: 'blocked' }); + }); + + it('does not allow stale submission restoration after cancellation or dismissal', () => { + expect(canRestoreRecovery({ source: 'cancelled' }, item)).toBe(false); + expect(canRestoreRecovery({ source: 'dismissed' }, item)).toBe(false); + expect(canRestoreRecovery({ source: 'blocked' }, item)).toBe(true); + expect(recoveryDisposition({ source: 'blocked' }, {})).toBeUndefined(); + }); + + it('retains tab-local safety decisions across reload, without message content', () => { + const first = createStore(); + first.set(recoveryDispositionsFamily('conversation'), { source: 'dismissed' }); + expect(sessionStorage.getItem('steer-recovery:conversation')).toBe('{"source":"dismissed"}'); + recoveryDispositionsFamily.remove('conversation'); + const reloaded = createStore(); + expect(reloaded.get(recoveryDispositionsFamily('conversation'))).toEqual({ + source: 'dismissed', + }); + expect(reloaded.get(recoveryDispositionsFamily('another-conversation'))).toEqual({}); + }); + + it('retains in-memory safety when browser storage is unavailable', () => { + const write = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + try { + const store = createStore(); + expect(() => + store.set(recoveryDispositionsFamily('conversation'), { source: 'blocked' }), + ).not.toThrow(); + expect(store.get(recoveryDispositionsFamily('conversation'))).toEqual({ source: 'blocked' }); + } finally { + write.mockRestore(); + } + }); + + it('treats an interrupted cancellation as held rather than locked or sendable after reload', () => { + const first = createStore(); + first.set(recoveryDispositionsFamily('conversation'), { source: 'cancelling' }); + recoveryDispositionsFamily.remove('conversation'); + expect(createStore().get(recoveryDispositionsFamily('conversation'))).toEqual({ + source: 'blocked', + }); + }); +}); diff --git a/client/src/components/Chat/Steering/recovery.ts b/client/src/components/Chat/Steering/recovery.ts new file mode 100644 index 00000000000..ec9bf070a6d --- /dev/null +++ b/client/src/components/Chat/Steering/recovery.ts @@ -0,0 +1,64 @@ +import { atomFamily, atomWithStorage, createJSONStorage } from 'jotai/utils'; +import type { QueuedMessage } from '~/store/families'; + +export type RecoveryDisposition = 'blocked' | 'cancelling' | 'cancelled' | 'dismissed'; +export type RecoveryDispositions = Partial>; + +/** Receipt-scoped safety decisions survive remounts and reloads in this tab. + * No message content is stored. A crashed cancellation stays held, not sendable. */ +export const recoveryDispositionsFamily = atomFamily((conversationId: string) => + atomWithStorage( + `steer-recovery:${conversationId}`, + {}, + createJSONStorage( + () => ({ + getItem(key) { + try { + return sessionStorage.getItem(key); + } catch { + return null; + } + }, + setItem(key, value) { + try { + sessionStorage.setItem(key, value); + } catch { + /* Keep the in-memory safety decision. */ + } + }, + removeItem(key) { + try { + sessionStorage.removeItem(key); + } catch { + /* Storage may be disabled. */ + } + }, + }), + { + reviver: (_key, value) => (value === 'cancelling' ? 'blocked' : value), + }, + ), + { getOnInit: true }, + ), +); + +export function recoveryDisposition( + dispositions: RecoveryDispositions, + item: Pick, +): RecoveryDisposition | undefined { + return item.recoverySteerId == null ? undefined : dispositions?.[item.recoverySteerId]; +} + +export function canRestoreRecovery( + dispositions: RecoveryDispositions, + item: Pick, +): boolean { + const disposition = recoveryDisposition(dispositions, item); + return disposition !== 'cancelled' && disposition !== 'dismissed'; +} + +export function blockRecovery(dispositions: RecoveryDispositions, steerId: string) { + return dispositions?.[steerId] != null + ? dispositions + : { ...dispositions, [steerId]: 'blocked' as const }; +} diff --git a/client/src/components/Chat/Subagents/ParentSubagentsProvider.test.tsx b/client/src/components/Chat/Subagents/ParentSubagentsProvider.test.tsx index 59eade9bda0..58c5221b604 100644 --- a/client/src/components/Chat/Subagents/ParentSubagentsProvider.test.tsx +++ b/client/src/components/Chat/Subagents/ParentSubagentsProvider.test.tsx @@ -1,10 +1,19 @@ import React from 'react'; -import { act, render } from '@testing-library/react'; +import { QueryKeys } from 'librechat-data-provider'; +import { act, render, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ParentSubagentIndex, ParentSubagentSummary } from 'librechat-data-provider'; import { ParentSubagentsProvider, useParentSubagents } from './ParentSubagentsProvider'; const mockUseParentSubagentsQuery = jest.fn(); +const renderProvider = (element: React.ReactElement, client = new QueryClient()) => + render(element, { + wrapper: ({ children }) => ( + {children} + ), + }); + jest.mock('~/data-provider', () => ({ useParentSubagentsQuery: (...args: unknown[]) => mockUseParentSubagentsQuery(...args), })); @@ -52,15 +61,17 @@ describe('ParentSubagentsProvider', () => { return null; }; - render( + renderProvider( , ); - expect(mockUseParentSubagentsQuery).toHaveBeenCalledWith('parent-conversation', { - enabled: true, - }); + expect(mockUseParentSubagentsQuery).toHaveBeenCalledWith( + 'parent-conversation', + { enabled: true }, + false, + ); expect(context?.byMessageId.get('parent-message')).toEqual([eventChild]); expect(context?.byThreadId.get('tool-thread')).toEqual(toolChild); let result: ParentSubagentIndex | undefined; @@ -70,4 +81,118 @@ describe('ParentSubagentsProvider', () => { expect(refetch).toHaveBeenCalledTimes(1); expect(result).toEqual(refreshed); }); + + it('marks an active parent run for prompt discovery', () => { + mockUseParentSubagentsQuery.mockReturnValue({ data: undefined, refetch: jest.fn() }); + renderProvider( + +
+ , + ); + expect(mockUseParentSubagentsQuery).toHaveBeenCalledWith( + 'parent-conversation', + { enabled: true }, + true, + ); + }); + + it('retains discovery on endpoint switches but resets for a different conversation', () => { + mockUseParentSubagentsQuery.mockReturnValue({ data: undefined, refetch: jest.fn() }); + const { rerender } = renderProvider( + +
+ , + ); + rerender( + +
+ , + ); + expect(mockUseParentSubagentsQuery).toHaveBeenLastCalledWith( + 'agent-chat', + { enabled: true }, + false, + ); + rerender( + +
+ , + ); + expect(mockUseParentSubagentsQuery).toHaveBeenLastCalledWith( + 'plain-chat', + { enabled: false }, + false, + ); + }); + + it.each(['history', 'tools', 'subagents'])( + 'restores discovery from cached %s after switching endpoints', + (evidence) => { + mockUseParentSubagentsQuery.mockReturnValue({ data: undefined, refetch: jest.fn() }); + const client = new QueryClient(); + if (evidence === 'history') + client.setQueryData([QueryKeys.messages, 'restored'], [{ endpoint: 'agents' }]); + if (evidence === 'tools') + client.setQueryData([QueryKeys.backgroundTasks, 'restored'], { tasks: [{}] }); + if (evidence === 'subagents') + client.setQueryData([QueryKeys.parentSubagents, 'restored'], { children: [eventChild] }); + renderProvider( + +
+ , + client, + ); + expect(mockUseParentSubagentsQuery).toHaveBeenLastCalledWith( + 'restored', + { enabled: true }, + false, + ); + }, + ); + + it('does not discover tasks for a new or genuinely non-agent conversation', () => { + mockUseParentSubagentsQuery.mockReturnValue({ data: undefined, refetch: jest.fn() }); + const { rerender } = renderProvider( + +
+ , + ); + expect(mockUseParentSubagentsQuery).toHaveBeenLastCalledWith( + 'plain', + { enabled: false }, + false, + ); + rerender( + +
+ , + ); + expect(mockUseParentSubagentsQuery).toHaveBeenLastCalledWith('new', { enabled: false }, false); + }); + + it('starts discovery when agent history arrives after the initial render', async () => { + mockUseParentSubagentsQuery.mockReturnValue({ data: undefined, refetch: jest.fn() }); + const client = new QueryClient(); + renderProvider( + +
+ , + client, + ); + expect(mockUseParentSubagentsQuery).toHaveBeenLastCalledWith( + 'restored', + { enabled: false }, + false, + ); + await act(async () => { + client.setQueryData([QueryKeys.messages, 'restored'], [{ endpoint: 'agents' }]); + }); + await waitFor(() => + expect(mockUseParentSubagentsQuery).toHaveBeenLastCalledWith( + 'restored', + { enabled: true }, + false, + ), + ); + }); }); diff --git a/client/src/components/Chat/Subagents/ParentSubagentsProvider.tsx b/client/src/components/Chat/Subagents/ParentSubagentsProvider.tsx index 82ec322f94f..4f7172aeef8 100644 --- a/client/src/components/Chat/Subagents/ParentSubagentsProvider.tsx +++ b/client/src/components/Chat/Subagents/ParentSubagentsProvider.tsx @@ -1,19 +1,32 @@ -import { createContext, useCallback, useContext, useMemo } from 'react'; -import type { ParentSubagentIndex, ParentSubagentSummary } from 'librechat-data-provider'; +import { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Constants, QueryKeys, EModelEndpoint } from 'librechat-data-provider'; +import type { + BackgroundTaskIndex, + TMessage, + ParentSubagentIndex, + ParentSubagentSummary, +} from 'librechat-data-provider'; import { useParentSubagentsQuery } from '~/data-provider'; type ParentSubagentsContextValue = { + conversationId: string; byMessageId: ReadonlyMap; byThreadId: ReadonlyMap; refresh: () => Promise; + isError: boolean; + discoveryEnabled: boolean; }; const emptyMap = new Map(); const emptyThreadMap = new Map(); const defaultValue: ParentSubagentsContextValue = { + conversationId: '', byMessageId: emptyMap, byThreadId: emptyThreadMap, refresh: async () => undefined, + isError: false, + discoveryEnabled: false, }; const ParentSubagentsContext = createContext(defaultValue); @@ -21,13 +34,45 @@ const ParentSubagentsContext = createContext(defaul export function ParentSubagentsProvider({ conversationId, enabled, + isSubmitting = false, children, }: { conversationId: string; enabled: boolean; + isSubmitting?: boolean; children: React.ReactNode; }) { - const { data, refetch } = useParentSubagentsQuery(conversationId, { enabled }); + const queryClient = useQueryClient(); + // Observe already-loaded history without fetching it or rerendering on streamed text. + const { data: hasAgentHistory = false } = useQuery( + [QueryKeys.messages, conversationId], + { + enabled: false, + select: (messages) => messages.some((message) => message.endpoint === EModelEndpoint.agents), + }, + ); + const hasCachedTasks = + (queryClient.getQueryData([QueryKeys.backgroundTasks, conversationId]) + ?.tasks.length ?? 0) > 0 || + (queryClient.getQueryData([QueryKeys.parentSubagents, conversationId]) + ?.children.length ?? 0) > 0; + const evidence = enabled || hasAgentHistory || hasCachedTasks; + const [discovery, setDiscovery] = useState({ conversationId, enabled: evidence }); + const observed = discovery.conversationId === conversationId && discovery.enabled; + const discoveryEnabled = + conversationId !== '' && + conversationId !== Constants.NEW_CONVO && + conversationId !== Constants.PENDING_CONVO && + (observed || evidence); + // A model switch cannot revoke a conversation's task ownership. Reset only on navigation. + if (discovery.conversationId !== conversationId || discovery.enabled !== discoveryEnabled) { + setDiscovery({ conversationId, enabled: discoveryEnabled }); + } + const { data, refetch, isError } = useParentSubagentsQuery( + conversationId, + { enabled: discoveryEnabled }, + isSubmitting, + ); const refresh = useCallback(async () => { const result = await refetch(); return result.data; @@ -48,8 +93,8 @@ export function ParentSubagentsProvider({ return updated === 0 ? left.threadId.localeCompare(right.threadId) : updated; }); } - return { byMessageId, byThreadId, refresh }; - }, [data, refresh]); + return { conversationId, byMessageId, byThreadId, refresh, isError, discoveryEnabled }; + }, [conversationId, data, refresh, isError, discoveryEnabled]); return ( {children} diff --git a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx index af5f68541e1..c62c42f1a2c 100644 --- a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx @@ -411,7 +411,11 @@ describe('SubagentActivity', () => { expect(screen.getByText('com_ui_subagent_control_message_truncated')).toBeInTheDocument(); expect(screen.getByText('com_ui_subagent_control_reason_task_completed')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_control_withdraw' })); + const accepted = screen.getByText('com_ui_subagent_control_status_accepted'); + const withdraw = screen.getByRole('button', { name: 'com_ui_subagent_control_withdraw' }); + expect(accepted.parentElement).toContainElement(withdraw); + expect(screen.queryByText('com_ui_queued_turn_starting')).toBeNull(); + fireEvent.click(withdraw); expect(onCancelControl).toHaveBeenCalledWith('control-1'); }); diff --git a/client/src/components/Chat/Subagents/SubagentActivity.tsx b/client/src/components/Chat/Subagents/SubagentActivity.tsx index b6e696b9bef..d6950ad7fc5 100644 --- a/client/src/components/Chat/Subagents/SubagentActivity.tsx +++ b/client/src/components/Chat/Subagents/SubagentActivity.tsx @@ -80,12 +80,27 @@ function SubagentControlHistory({ key={control.invocationId} className="border-border-light bg-surface-secondary rounded-lg border px-3 py-2 text-sm" > -
+
- {localize(CONTROL_ACTION_LABELS[control.action])} - + + {localize(CONTROL_ACTION_LABELS[control.action])} + + {localize(CONTROL_STATUS_LABELS[control.status])} + {control.status === 'accepted' && + control.controlId != null && + onCancelControl != null && ( + + )}
{control.message != null && control.message !== '' && (
@@ -105,19 +120,6 @@ function SubagentControlHistory({ )}
)} - {control.status === 'accepted' && - control.controlId != null && - onCancelControl != null && ( - - )}
); })} diff --git a/client/src/components/Chat/__tests__/ChatView.spec.tsx b/client/src/components/Chat/__tests__/ChatView.spec.tsx index 2676a38dd2c..1be3ca02a84 100644 --- a/client/src/components/Chat/__tests__/ChatView.spec.tsx +++ b/client/src/components/Chat/__tests__/ChatView.spec.tsx @@ -180,4 +180,13 @@ describe('ChatView composer column', () => { expect(composerColumn).not.toHaveClass('overflow-y-auto'); expect(composerColumn).not.toHaveClass('scrollbar-gutter-stable'); }); + + test('layers composer overlays above positioned tool glyphs in the message column', () => { + const { container } = render(); + + const composerColumn = container.querySelector('.scrollbar-gutter-spacer'); + + expect(composerColumn).toHaveClass('[view-transition-name:chat-form]'); + expect(composerColumn).toHaveClass('relative', 'z-10'); + }); }); diff --git a/client/src/components/Chat/__tests__/Header.stacking.spec.tsx b/client/src/components/Chat/__tests__/Header.stacking.spec.tsx new file mode 100644 index 00000000000..712f43ca7ad --- /dev/null +++ b/client/src/components/Chat/__tests__/Header.stacking.spec.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import Header from '../Header'; + +const mockEndpoint = { current: 'agents' }; + +jest.mock('react-router-dom', () => ({ + useParams: () => ({ conversationId: 'convo-1' }), +})); +jest.mock('recoil', () => ({ + useRecoilValue: (selector: string) => + selector === 'effectiveEndpoint' ? mockEndpoint.current : false, +})); +jest.mock('librechat-data-provider', () => ({ + getConfigDefaults: () => ({ interface: {} }), + Constants: { NEW_CONVO: 'new' }, + EModelEndpoint: { agents: 'agents' }, + PermissionTypes: { BOOKMARKS: 'bookmarks', MULTI_CONVO: 'multi_convo', TEMPORARY_CHAT: 'temp' }, + Permissions: { USE: 'use' }, +})); +jest.mock('~/data-provider', () => ({ useGetStartupConfig: () => ({ data: undefined }) })); +jest.mock('~/hooks', () => ({ useHasAccess: () => false })); +jest.mock('~/store', () => ({ + __esModule: true, + default: { + sidebarExpanded: {}, + isSubmittingFamily: () => ({}), + effectiveEndpointByIndex: () => 'effectiveEndpoint', + }, +})); +jest.mock('~/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), +})); +jest.mock('../Menus', () => ({ + OpenSidebar: () => null, + PresetsMenu: () => null, + NewChat: () => null, + HeaderMenu: () => null, +})); +jest.mock('../TemporaryChat', () => ({ + TemporaryChat: () => null, + TemporaryChatIndicator: () => null, +})); +jest.mock('../Trace', () => ({ useTraceControl: () => ({ show: false }) })); +jest.mock('../BackgroundTasks', () => ({ + BackgroundTasksButton: jest.fn(() =>
), +})); +jest.mock('../Menus/Endpoints/ModelSelector', () => () => null); +jest.mock('../ExportAndShareMenu', () => () => null); +jest.mock('../SubagentThreadLink', () => () => null); +jest.mock('../Menus/BookmarkMenu', () => () => null); +jest.mock('../AddMultiConvo', () => () => null); + +describe('Header stacking', () => { + const backgroundTasks = jest.requireMock('../BackgroundTasks').BackgroundTasksButton as jest.Mock; + + beforeEach(() => { + backgroundTasks.mockClear(); + mockEndpoint.current = 'agents'; + }); + + test('keeps header controls above the z-10 composer approval review', () => { + const { container } = render(
); + + expect(container.firstElementChild).toHaveClass('absolute', 'top-0', 'z-20'); + expect(backgroundTasks).toHaveBeenCalled(); + }); + + test('keeps conversation task controls mounted across endpoint switches', () => { + const { rerender } = render(
); + mockEndpoint.current = 'openAI'; + rerender(
); + expect(screen.getByTestId('conversation-tasks')).toBeInTheDocument(); + }); + + test('does not mount parent task controls on child threads', () => { + render(
); + expect(backgroundTasks).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 0d09ec07664..4e21b8307b0 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -33,6 +33,7 @@ import { useUnpinDroppedConversation, } from './dnd'; import { useLocalize, TranslationKeys, useElementSize, useOuterScrollWindow } from '~/hooks'; +import { groupConversationsWithRunning, RUNNING_CHATS_GROUP } from './running'; import { facetFilterCountAtom, resetFacetsAtom } from './facets'; import { groupConversations, cn } from '~/utils'; import { useActiveJobs } from '~/data-provider'; @@ -172,10 +173,14 @@ const DateLabel: FC<{ groupName: string; isFirst?: boolean; isAlphabetical?: boo const displayName = localize(groupName as TranslationKeys) || groupName; return (

= ({ // Fetch active job IDs for showing generation indicators const { data: activeJobsData } = useActiveJobs(); - const activeJobIds = useMemo( - () => new Set(activeJobsData?.activeJobIds ?? []), - [activeJobsData?.activeJobIds], - ); + const activeJobIdsRef = useRef | null>(null); + const activeJobIds = useMemo(() => { + const ids = activeJobsData?.activeJobIds ?? []; + const next = new Set(ids); + const previous = activeJobIdsRef.current; + if (previous && next.size === previous.size && ids.every((id) => previous.has(id))) { + return previous; + } + activeJobIdsRef.current = next; + return next; + }, [activeJobsData?.activeJobIds]); const filteredConversations = useMemo( () => rawConversations.filter(Boolean) as TConversation[], [rawConversations], ); - /** The pinned section above carries pins, so they stay out of these groups — except in - * the archive, which that section does not cover: an archived pin would otherwise be - * absent from the sidebar entirely rather than merely further down it. */ - const groupedConversations = useMemo( + /** The pinned section carries pins except in the archive or during search, when it is + * hidden. Keep matching pins in the Chats results instead of showing an empty list. */ + const includePinned = isArchivedView || !!search.query; + const datedConversations = useMemo( () => groupConversations(filteredConversations, { + field: sort.field, + direction: sort.direction, + includePinned, + }), + [filteredConversations, includePinned, sort.direction, sort.field], + ); + /** The archive keeps its server order, while search still promotes active matches. */ + const groupedConversations = useMemo( + () => + groupConversationsWithRunning(datedConversations, activeJobIds, { field: sort.field, direction: sort.direction, includePinned: isArchivedView, }), - [filteredConversations, isArchivedView, sort.direction, sort.field], + [datedConversations, activeJobIds, isArchivedView, sort.direction, sort.field], ); - /* Pins are stripped from the date groups. An all-pin page leaves the - virtual list with no rows, so onRowsRendered never fires and later + /* Outside search, pins are stripped from the date groups. An all-pin page leaves + the virtual list with no rows, so onRowsRendered never fires and later unpinned chats stay unreachable. Ask for another page only when the conversations input actually changes; a failed fetchNextPage leaves the same array and must not loop. */ diff --git a/client/src/components/Conversations/__tests__/Conversations.test.tsx b/client/src/components/Conversations/__tests__/Conversations.test.tsx index 91bd22aba0d..a297b3332c2 100644 --- a/client/src/components/Conversations/__tests__/Conversations.test.tsx +++ b/client/src/components/Conversations/__tests__/Conversations.test.tsx @@ -15,6 +15,7 @@ import store from '~/store'; /* The section resolves a conversation's project from the query cache, so the * tree needs a client even though the data hooks themselves are mocked. */ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +let mockActiveJobIds: string[] = []; jest.mock('react-virtualized', () => { const actual = jest.requireActual('react-virtualized'); @@ -102,19 +103,21 @@ jest.mock('@librechat/client', () => ({ })); jest.mock('~/data-provider', () => ({ - useActiveJobs: () => ({ data: undefined }), + useActiveJobs: () => ({ data: { activeJobIds: mockActiveJobIds } }), useAssignConversationToProjectMutation: () => ({ mutate: jest.fn() }), usePinConversationMutation: () => ({ mutate: jest.fn() }), })); jest.mock('~/utils', () => ({ - groupConversations: () => [], + groupConversations: jest.fn(jest.requireActual('~/utils/convos').groupConversations), cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })); jest.mock('../Convo', () => ({ __esModule: true, - default: () =>
, + default: ({ conversation }: { conversation: TConversation }) => ( +
{conversation.title}
+ ), })); const pinnedConvo = { @@ -126,6 +129,77 @@ const pinnedConvo = { updatedAt: new Date().toISOString(), } as TConversation; +describe('Conversations: live running order', () => { + const containerRef = createRef(); + const newer = { + conversationId: 'newer', + title: 'Newer idle chat', + createdAt: '2026-09-26T00:00:00.000Z', + updatedAt: '2026-09-26T00:00:00.000Z', + } as TConversation; + const running = { + conversationId: 'running', + title: 'Older running chat', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + } as TConversation; + + afterEach(() => { + mockActiveJobIds = []; + }); + + it('promotes and restores a chat without changing its paged input', () => { + const conversations = [newer, running]; + const renderList = () => ( + + + + + + + + ); + const rowOrder = () => screen.getAllByTestId('convo').map((row) => row.textContent); + + const view = render(renderList()); + const groupConversationsMock = jest.requireMock('~/utils').groupConversations as jest.Mock; + groupConversationsMock.mockClear(); + expect(rowOrder()).toEqual(['Newer idle chat', 'Older running chat']); + expect(screen.queryByRole('heading', { name: 'com_a11y_chats_running_section' })).toBeNull(); + + mockActiveJobIds = ['running']; + view.rerender(renderList()); + expect( + screen.getByRole('heading', { name: 'com_a11y_chats_running_section' }), + ).toBeInTheDocument(); + expect(rowOrder()).toEqual(['Older running chat', 'Newer idle chat']); + expect(groupConversationsMock).not.toHaveBeenCalled(); + + mockActiveJobIds = ['running']; + view.rerender(renderList()); + expect(groupConversationsMock).not.toHaveBeenCalled(); + expect(rowOrder()).toEqual(['Older running chat', 'Newer idle chat']); + + mockActiveJobIds = []; + view.rerender(renderList()); + expect(screen.queryByRole('heading', { name: 'com_a11y_chats_running_section' })).toBeNull(); + expect(rowOrder()).toEqual(['Newer idle chat', 'Older running chat']); + expect(conversations).toEqual([newer, running]); + }); +}); + describe('Conversations: pinned chats live in PinnedSection', () => { const containerRef = createRef(); @@ -163,8 +237,42 @@ describe('Conversations: pinned chats live in PinnedSection', () => { ); it('does not render a pinned header inside the chats list', () => { - const { queryByText } = renderConversations([pinnedConvo]); + const { queryByText, queryByTestId } = renderConversations([pinnedConvo]); expect(queryByText('com_ui_pinned')).not.toBeInTheDocument(); + expect(queryByTestId('convo')).not.toBeInTheDocument(); + }); + + it('shows pinned search matches in Chats while the Pinned section is hidden', () => { + renderConversations([pinnedConvo, pinnedConvo], 'Pinned Chat'); + + expect(screen.getAllByTestId('convo')).toHaveLength(1); + expect(screen.getByTestId('convo')).toHaveTextContent('Pinned Chat'); + expect(screen.queryByText('com_ui_no_search_results')).not.toBeInTheDocument(); + }); + + it('keeps running matches promoted when search includes pinned chats', () => { + mockActiveJobIds = ['running']; + const running = { + ...pinnedConvo, + conversationId: 'running', + title: 'Running match', + pinned: false, + updatedAt: '2026-01-01T00:00:00.000Z', + } as TConversation; + + try { + renderConversations([pinnedConvo, running], 'match'); + + expect(screen.getAllByTestId('convo').map((row) => row.textContent)).toEqual([ + 'Running match', + 'Pinned Chat', + ]); + expect( + screen.getByRole('heading', { name: 'com_a11y_chats_running_section' }), + ).toBeInTheDocument(); + } finally { + mockActiveJobIds = []; + } }); it('does not render a duplicate new chat button in the chats header', () => { diff --git a/client/src/components/Conversations/__tests__/DateLabel.test.tsx b/client/src/components/Conversations/__tests__/DateLabel.test.tsx index b2df575e922..439a20d8d64 100644 --- a/client/src/components/Conversations/__tests__/DateLabel.test.tsx +++ b/client/src/components/Conversations/__tests__/DateLabel.test.tsx @@ -8,6 +8,8 @@ jest.mock('~/hooks', () => ({ const translations: Record = { com_a11y_chats_date_section: `Chats from ${params?.date ?? ''}`, com_a11y_chats_alpha_section: `Chats with titles starting with ${params?.letter ?? ''}`, + com_a11y_chats_running_section: 'Running chats', + com_ui_running_chats: 'Running', com_ui_date_today: 'Today', com_ui_date_yesterday: 'Yesterday', com_ui_date_previous_7_days: 'Previous 7 days', @@ -22,6 +24,13 @@ describe('DateLabel', () => { expect(screen.getByRole('heading', { level: 2, name: 'Chats from Today' })).toBeInTheDocument(); }); + it('announces the running group without describing it as a date', () => { + render(); + expect(screen.getByRole('heading', { level: 2, name: 'Running chats' })).toHaveTextContent( + 'Running', + ); + }); + it('uses the alphabetical accessible name for title groups', () => { render(); expect( diff --git a/client/src/components/Conversations/__tests__/running.spec.ts b/client/src/components/Conversations/__tests__/running.spec.ts new file mode 100644 index 00000000000..42996ed427b --- /dev/null +++ b/client/src/components/Conversations/__tests__/running.spec.ts @@ -0,0 +1,91 @@ +import type { TConversation } from 'librechat-data-provider'; +import type { ConversationGroupOptions } from '~/utils/convos'; +import { groupConversationsWithRunning as partitionGroups, RUNNING_CHATS_GROUP } from '../running'; +import { groupConversations } from '~/utils/convos'; + +const convo = (conversationId: string, daysAgo: number, pinned = false): TConversation => + ({ + conversationId, + title: conversationId, + updatedAt: new Date(Date.now() - daysAgo * 86_400_000).toISOString(), + createdAt: new Date(Date.now() - 90 * 86_400_000).toISOString(), + pinned, + }) as TConversation; + +const ids = (groups: ReturnType) => + groups.flatMap(([, conversations]) => conversations.map((c) => c.conversationId)); + +const newestFirst = { field: 'updatedAt' as const, direction: 'desc' as const }; +const groupConversationsWithRunning = ( + conversations: TConversation[], + activeJobIds: ReadonlySet, + options: ConversationGroupOptions, +) => partitionGroups(groupConversations(conversations, options), activeJobIds, options); + +describe('groupConversationsWithRunning', () => { + it('returns the same date groups when the polled jobs do not affect loaded rows', () => { + const dated = groupConversations([convo('idle', 1)], newestFirst); + expect(partitionGroups(dated, new Set(), newestFirst)).toBe(dated); + expect(partitionGroups(dated, new Set(['not-loaded']), newestFirst)).toBe(dated); + }); + it('lifts loaded running chats above newer idle chats without changing the server rows', () => { + const newer = convo('newer', 0); + const running = convo('running', 45); + const older = convo('older', 60); + const conversations = [newer, running, older]; + const snapshot = JSON.stringify(conversations); + + const groups = groupConversationsWithRunning( + conversations, + new Set(['running', 'not-loaded']), + newestFirst, + ); + + expect(groups[0]).toEqual([RUNNING_CHATS_GROUP, [running]]); + expect(ids(groups)).toEqual(['running', 'newer', 'older']); + expect(JSON.stringify(conversations)).toBe(snapshot); + }); + + it('keeps multiple running chats in their fetched order and deduplicates overlapping pages', () => { + const older = convo('older', 70); + const recent = convo('recent', 2); + const pinned = convo('pinned', 1, true); + const groups = groupConversationsWithRunning( + [recent, pinned, older, recent, older], + new Set(['older', 'recent', 'pinned']), + newestFirst, + ); + + expect(groups).toEqual([[RUNNING_CHATS_GROUP, [recent, older]]]); + expect(ids(groups)).toEqual(['recent', 'older']); + }); + + it('restores date grouping as soon as a run finishes', () => { + const conversations = [convo('newer', 0), convo('running', 50)]; + const running = groupConversationsWithRunning(conversations, new Set(['running']), newestFirst); + const finished = groupConversationsWithRunning(conversations, new Set(), newestFirst); + + expect(running[0][0]).toBe(RUNNING_CHATS_GROUP); + expect(finished).toEqual(groupConversations(conversations, newestFirst)); + expect(ids(finished)).toEqual(['newer', 'running']); + }); + + it.each([ + { field: 'updatedAt' as const, direction: 'asc' as const }, + { field: 'createdAt' as const, direction: 'desc' as const }, + { field: 'title' as const, direction: 'asc' as const }, + ])('respects the explicit $field $direction sort', (options) => { + const conversations = [convo('newer', 0), convo('running', 50)]; + expect(groupConversationsWithRunning(conversations, new Set(['running']), options)).toEqual( + groupConversations(conversations, options), + ); + }); + + it('keeps archive grouping untouched, including archived pinned rows', () => { + const conversations = [convo('newer', 0), convo('pinned', 50, true)]; + const options = { ...newestFirst, includePinned: true }; + expect(groupConversationsWithRunning(conversations, new Set(['pinned']), options)).toEqual( + groupConversations(conversations, options), + ); + }); +}); diff --git a/client/src/components/Conversations/running.ts b/client/src/components/Conversations/running.ts new file mode 100644 index 00000000000..9d874ec771d --- /dev/null +++ b/client/src/components/Conversations/running.ts @@ -0,0 +1,39 @@ +import type { TConversation, GroupedConversations } from 'librechat-data-provider'; +import type { ConversationGroupOptions } from '~/utils/convos'; + +export const RUNNING_CHATS_GROUP = 'com_ui_running_chats'; + +/** Partition the existing server-ordered groups without re-sorting them on every job update. */ +export function groupConversationsWithRunning( + groups: GroupedConversations, + activeJobIds: ReadonlySet, + options: ConversationGroupOptions, +): GroupedConversations { + if ( + options.includePinned || + options.field !== 'updatedAt' || + options.direction !== 'desc' || + activeJobIds.size === 0 + ) { + return groups; + } + + const running: TConversation[] = []; + const remaining: GroupedConversations = []; + for (const [groupName, conversations] of groups) { + const idle: TConversation[] = []; + for (const conversation of conversations) { + const id = conversation.conversationId; + if (id && activeJobIds.has(id)) { + running.push(conversation); + } else { + idle.push(conversation); + } + } + if (idle.length > 0) { + remaining.push([groupName, idle]); + } + } + + return running.length === 0 ? groups : [[RUNNING_CHATS_GROUP, running], ...remaining]; +} diff --git a/client/src/components/Messages/Content/Error/registry.ts b/client/src/components/Messages/Content/Error/registry.ts index 1fc44b1a95a..3f878637a03 100644 --- a/client/src/components/Messages/Content/Error/registry.ts +++ b/client/src/components/Messages/Content/Error/registry.ts @@ -29,6 +29,8 @@ export const errorCopy: Record = { [ErrorTypes.STREAM_EXPIRED]: 'com_error_stream_expired', [ErrorTypes.MODEL_NOT_FOUND]: 'com_error_model_not_found', [ErrorTypes.MODEL_RATE_LIMIT]: 'com_error_model_rate_limit', + [ErrorTypes.MODEL_STREAM_CLOSED]: 'com_error_model_stream_closed', + [ErrorTypes.MODEL_STREAM_STALLED]: 'com_error_model_stream_stalled', [ErrorTypes.COMPACTION_FAILED]: 'com_error_compaction_failed', [ErrorTypes.AUTH_FAILED]: 'com_error_auth_failed', [ErrorTypes.AUTH_RATE_LIMITED]: 'com_error_auth_rate_limited', diff --git a/client/src/components/Messages/Content/__tests__/Error.spec.tsx b/client/src/components/Messages/Content/__tests__/Error.spec.tsx index 944bbc62f83..de0c0772f4f 100644 --- a/client/src/components/Messages/Content/__tests__/Error.spec.tsx +++ b/client/src/components/Messages/Content/__tests__/Error.spec.tsx @@ -198,6 +198,43 @@ describe('Error — reader-facing provider and fallback copy', () => { expectReadable(); }); + it.each([ + [ + ErrorTypes.MODEL_STREAM_CLOSED, + 'com_error_model_stream_closed', + 'The model provider closed the connection before the response finished. Try again.', + ], + [ + ErrorTypes.MODEL_STREAM_STALLED, + 'com_error_model_stream_stalled', + 'The model provider stopped sending the response, and the request timed out. Try again.', + ], + ])( + 'localizes a %s error even when it carries older-client fallback prose', + (type, key, prose) => { + renderError(`${prose}\n${JSON.stringify({ type })}`, providerMessage); + + expect(screen.getByText(catalog[key])).toBeInTheDocument(); + expect(screen.queryByText(prose)).not.toBeInTheDocument(); + expect(screen.queryByText(/terminated/i)).not.toBeInTheDocument(); + expectReadable(); + }, + ); + + it('keeps fallback prose when an older client cannot recognize the server error type', () => { + const prose = + 'The model provider closed the connection before the response finished. Try again.'; + renderError( + `${prose}\n${JSON.stringify({ type: 'newer_model_stream_failure' })}`, + providerMessage, + ); + + expect(screen.getByText(prose)).toBeInTheDocument(); + expect(screen.queryByText(catalog.com_error_unknown)).not.toBeInTheDocument(); + expect(document.body.textContent).not.toContain('{'); + expectReadable(); + }); + it('keeps provider text for a LangChain code without localized copy, minus the URL', () => { const raw = 'An error occurred while processing the request: could not parse output\n\nTroubleshooting URL: https://docs.langchain.com/oss/javascript/langchain/errors/OUTPUT_PARSING_FAILURE/\n'; diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index e112c121173..728180518f2 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -2,17 +2,26 @@ import { memo, useCallback, useEffect, useRef } from 'react'; import { EarthIcon } from 'lucide-react'; import { ControlCombobox } from '@librechat/client'; import { useFormContext, Controller } from 'react-hook-form'; -import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provider'; +import { + AgentCapabilities, + normalizeAgentSelectorLimit, + defaultAgentFormValues, +} from 'librechat-data-provider'; import type { Agent, AgentCreateParams, StatefulCodeEnvironment } from 'librechat-data-provider'; import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query'; import type { TAgentCapabilities, AgentForm } from '~/common'; import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils'; +import { useListAgentsQuery, useGetStartupConfig } from '~/data-provider'; import { useLocalize, useAgentDefaultPermissionLevel } from '~/hooks'; import { mergeDirtyToolsWithServerActions } from './agentTools'; -import { useListAgentsQuery } from '~/data-provider'; const keys = new Set(Object.keys(defaultAgentFormValues)); +/** Dropdown cap: 480px tall; the unsearched list cap comes from + * `interface.agentSelectorLimit` (default 10), and the search field covers + * agents past the cut. */ +const SELECTOR_MAX_HEIGHT = 480; + function AgentSelect({ agentQuery, selectedAgentId = null, @@ -40,6 +49,8 @@ function AgentSelect({ const dirtyFieldsRef = useRef(dirtyFields); dirtyFieldsRef.current = dirtyFields; const permissionLevel = useAgentDefaultPermissionLevel(); + const { data: startupConfig } = useGetStartupConfig(); + const selectorLimit = normalizeAgentSelectorLimit(startupConfig?.interface?.agentSelectorLimit); const { data: agents = null } = useListAgentsQuery( { requiredPermission: permissionLevel }, @@ -274,6 +285,8 @@ function AgentSelect({ selectPlaceholder={field?.value?.value ?? createAgent} iconSide="right" searchPlaceholder={localize('com_agents_search_name')} + popoverMaxHeight={SELECTOR_MAX_HEIGHT} + unsearchedLimit={selectorLimit} SelectIcon={field?.value?.icon} setValue={onSelect} items={ diff --git a/client/src/components/SidePanel/Agents/Code/Settings.tsx b/client/src/components/SidePanel/Agents/Code/Settings.tsx index 57adc4b1399..2585d2b6d69 100644 --- a/client/src/components/SidePanel/Agents/Code/Settings.tsx +++ b/client/src/components/SidePanel/Agents/Code/Settings.tsx @@ -142,17 +142,17 @@ export default function CodeSettings() {
-
+
{localize('com_ui_stateful_sessions')}
- +
-

+

{localize('com_nav_info_stateful_sessions')}

@@ -174,7 +174,7 @@ export default function CodeSettings() { {executionEnvironments.length > 0 && ( <>