Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions client/src/components/Chat/Input/ChatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,10 @@ const ChatForm = memo(function ChatForm({
);
/** The chip's actions hide while a replacement upload or inline move is in flight, so the
* same original cannot be acted on twice. */
const { isActionPending } = pastedTextEdit;
const isPasteActionPending = useCallback(
(file: ExtendedFile) => pastedTextEdit.isActionPending(file.file_id),
[pastedTextEdit],
(file: ExtendedFile) => isActionPending(file.file_id),
[isActionPending],
);

const { submitMessage, submitPrompt } = useSubmitMessage();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,34 @@ import { QueryKeys, FileSources, EModelEndpoint } from 'librechat-data-provider'
import { render, screen, within, waitFor, fireEvent } from '@testing-library/react';
import type { TFile, TFileUpload, TConversation } from 'librechat-data-provider';
import type { ChatFormValues, TAskFunction } from '~/common';
import type { TranslationKeys } from '~/hooks/useLocalize';
import { getDraft, getPendingDraftId, setDraft } from '~/utils';
import ChatForm, { toRestoredComposerFile } from '../ChatForm';
import { ChatContext, ChatFormProvider } from '~/Providers';
import { AuthContextProvider } from '~/hooks/AuthContext';
import * as FileContainer from '../Files/FileContainer';
import store from '~/store';

const mockUpload = jest.fn();
const mockAsk = jest.fn();

// Production keeps `t` stable between language changes; the global test double does not.
jest.mock('react-i18next', () => {
const actual = jest.requireActual<typeof import('react-i18next')>('react-i18next');
const t = (key: TranslationKeys, options?: import('i18next').TOptions) =>
jest.requireActual<typeof import('~/locales/i18n')>('~/locales/i18n').default.t(key, options);
return {
...actual,
useTranslation: () => ({
t,
i18n: {
...jest.requireActual<typeof import('~/locales/i18n')>('~/locales/i18n').default,
changeLanguage: jest.fn(),
},
}),
};
});

jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
Expand Down Expand Up @@ -449,6 +468,31 @@ describe('ChatForm attachments', () => {
expect(textarea).toHaveValue('hi');
}, 20000);

test('does not redraw an attached document on each keystroke', async () => {
mockUpload.mockImplementation((body: FormData) =>
Promise.resolve({
...uploadResponse,
filename: 'report.pdf',
type: 'application/pdf',
temp_file_id: body.get('file_id') as string,
}),
);
const { container } = renderComposer();
await attach(container, new File(['document'], 'report.pdf', { type: 'application/pdf' }));
expect(await screen.findByRole('button', { name: 'report.pdf' })).toBeVisible();
const textarea = screen.getByTestId('text-input');
await userEvent.click(textarea);
const preview = jest.spyOn(FileContainer, 'default');

await userEvent.type(textarea, 'read this document');

// Allow the upload cache to reconcile once, not once per keystroke.
expect(preview.mock.calls.length).toBeLessThanOrEqual(1);
await userEvent.click(sendButton());
expect(mockAsk).toHaveBeenCalledWith({ text: 'read this document' }, expect.any(Object));
expect(textarea).toHaveValue('');
});

/**
* The composer is the app's busiest surface: every keystroke already re-renders
* it for the row count and the send button's enabled state, so anything that
Expand Down
12 changes: 9 additions & 3 deletions client/src/data-provider/CodeEnvironments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
TCodeEnvironmentStatusResponse,
TCodeEnvironmentsResponse,
} from 'librechat-data-provider';
import type { QueryFilters } from '@tanstack/react-query';
import type { QueryFilters, UseQueryOptions } from '@tanstack/react-query';
import type { SetterOrUpdater } from 'recoil';
import type { CodeEnvironmentReconciliationRequest } from '~/store/codeEnvironmentReconciliation';
import {
Expand Down Expand Up @@ -57,8 +57,14 @@ export function useCodeEnvironmentStatusQuery(id: string, enabled = true) {
return useQuery(codeEnvironmentStatusOptions(id, enabled));
}

export function useCodeEnvironmentStatusQueries(ids: string[], enabled = true) {
return useQueries({ queries: ids.map((id) => codeEnvironmentStatusOptions(id, enabled)) });
export function useCodeEnvironmentStatusQueries(
ids: string[],
enabled = true,
options?: Pick<UseQueryOptions, 'notifyOnChangeProps'>,
) {
return useQueries({
queries: ids.map((id) => ({ ...codeEnvironmentStatusOptions(id, enabled), ...options })),
});
}

/** Refresh discovery, not the conversation draft or expanded agent editor records. */
Expand Down
20 changes: 15 additions & 5 deletions client/src/hooks/Agents/__tests__/useCodeWorkspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ describe('useCodeWorkspace', () => {
const { result } = renderHook(() => useCodeWorkspace(conversation()));
expect(result.current.required).toBe(false);
expect(result.current.state).toBe('not_required');
expect(mockStatus).toHaveBeenLastCalledWith(['personal-vm'], false);
expect(mockStatus).toHaveBeenLastCalledWith(['personal-vm'], false, {
notifyOnChangeProps: ['data', 'isLoading', 'isError'],
});
},
);

Expand Down Expand Up @@ -192,7 +194,9 @@ describe('useCodeWorkspace', () => {
expect(result.current.selections).toEqual([
{ environmentId: 'personal-vm', workspaceId: 'project-a' },
]);
expect(mockStatus).toHaveBeenCalledWith(['personal-vm'], true);
expect(mockStatus).toHaveBeenCalledWith(['personal-vm'], true, {
notifyOnChangeProps: ['data', 'isLoading', 'isError'],
});
});

it('does not emit a selection-less decision until the API advertises support', () => {
Expand Down Expand Up @@ -394,7 +398,9 @@ describe('useCodeWorkspace', () => {
const { result } = renderHook(() => useCodeWorkspace({ ...conversation(), agent_id }));
expect(result.current.required).toBe(false);
expect(result.current.state).toBe('not_required');
expect(mockStatus).toHaveBeenLastCalledWith([], false);
expect(mockStatus).toHaveBeenLastCalledWith([], false, {
notifyOnChangeProps: ['data', 'isLoading', 'isError'],
});
});

it('still blocks missing saved-agent metadata alongside an ephemeral agent', () => {
Expand Down Expand Up @@ -532,7 +538,9 @@ describe('useCodeWorkspace', () => {
expect(result.current.resolveSubmission()).toEqual({
codeEnvironmentMode: 'without_attached',
});
expect(mockStatus).toHaveBeenLastCalledWith([], false);
expect(mockStatus).toHaveBeenLastCalledWith([], false, {
notifyOnChangeProps: ['data', 'isLoading', 'isError'],
});
});

it('does not replace a saved workspace that disappeared', () => {
Expand Down Expand Up @@ -1309,7 +1317,9 @@ describe('useCodeWorkspace', () => {
expect(result.current.required).toBe(true);
expect(result.current.state).toBe('unavailable');
expect(result.current.selections).toBeUndefined();
expect(mockStatus).toHaveBeenCalledWith([], false);
expect(mockStatus).toHaveBeenCalledWith([], false, {
notifyOnChangeProps: ['data', 'isLoading', 'isError'],
});
});

it('does not gate a non-agent conversation', () => {
Expand Down
44 changes: 43 additions & 1 deletion client/src/hooks/Agents/__tests__/workspaceLifecycle.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,18 @@ function setup(conversation: TConversation = saved) {
});
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client }, children);
return { client, ...renderHook(() => useCodeWorkspace(conversation), { wrapper }) };
const render = jest.fn();
return {
client,
render,
...renderHook(
() => {
render();
return useCodeWorkspace(conversation);
},
{ wrapper },
),
};
}

describe('workspace status recovery', () => {
Expand All @@ -78,6 +89,37 @@ describe('workspace status recovery', () => {
client.clear();
});

it('keeps unchanged status refreshes out of the composer render path', async () => {
const request = jest.spyOn(dataService, 'getCodeEnvironmentStatus').mockResolvedValue(ready);
const { result, client, render, unmount } = setup();
await waitFor(() => expect(result.current.state).toBe('ready'));
const renders = render.mock.calls.length;
let resolve = (_status: TCodeEnvironmentStatusResponse) => {};
request.mockReturnValueOnce(new Promise((done) => (resolve = done)));

let refresh: Promise<void>;
await act(async () => {
refresh = client.invalidateQueries(DynamicQueryKeys.codeEnvironmentStatus('vm'));
});
expect(request).toHaveBeenCalledTimes(2);
expect(render).toHaveBeenCalledTimes(renders);
await act(async () => {
resolve({ ...ready, workspaces: [{ id: 'project' }] });
await refresh;
});
expect(render).toHaveBeenCalledTimes(renders);
expect(result.current.canSubmit).toBe(true);

request.mockResolvedValue({ ...ready, workspaces: [] });
await act(async () => {
await client.invalidateQueries(DynamicQueryKeys.codeEnvironmentStatus('vm'));
});
await waitFor(() => expect(result.current.state).toBe('missing'));
expect(result.current.canSubmit).toBe(false);
unmount();
client.clear();
});

it('recovers an exhausted background failure without replacing the saved selection', async () => {
const request = jest.spyOn(dataService, 'getCodeEnvironmentStatus').mockResolvedValue(ready);
const { result, client, unmount } = setup();
Expand Down
2 changes: 2 additions & 0 deletions client/src/hooks/Agents/useCodeWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ export default function useCodeWorkspace(
const statuses = useCodeEnvironmentStatusQueries(
attachedEnvironments.map(({ id }) => id),
required && selectionMetadataComplete,
// Poll progress is not workspace state; keep unchanged refreshes off the send path.
{ notifyOnChangeProps: ['data', 'isLoading', 'isError'] },
);
const storedSelections = conversation?.codeWorkspaces;
const attachedEnvironmentIds = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
TEphemeralAgent,
} from 'librechat-data-provider';
import type { ReactNode } from 'react';
import type { ExtendedFile } from '~/common';
import { pendingReasoningOverrideFamily } from '~/components/Chat/Input/Composer/state';
import { activeUsageResponseIdFamily, pendingUsageFamily } from '~/store/usage';
import { revealedQueuedTurnFamily } from '~/store/steer';
Expand Down Expand Up @@ -165,9 +166,11 @@ function renderAsk(
isSubmitting?: boolean;
reasoningOverride?: TReasoningOverride;
agentId?: string;
files?: Map<string, ExtendedFile>;
} = {},
) {
const setMessages = jest.fn();
const setFiles = jest.fn();
const setSubmission = jest.fn();
const getMessages = jest.fn(() => messages);
const immutableConversation = conversation(conversationId);
Expand All @@ -194,17 +197,20 @@ function renderAsk(
getMessages,
setMessages,
setSubmission,
files: options.files,
setFiles,
setConversation: mockSetConversation,
}),
{ wrapper },
);

return { ...hook, getMessages, setMessages, setSubmission, reasoningStore };
return { ...hook, getMessages, setMessages, setFiles, setSubmission, reasoningStore };
}

describe('useChatFunctions ask', () => {
beforeEach(() => {
mockEndpointsQueryData.current = undefined;
localStorage.clear();
jest.clearAllMocks();
mockAgentQueryData.current = undefined;
mockGetLatestConversation.mockReturnValue(null);
Expand Down Expand Up @@ -236,6 +242,50 @@ describe('useChatFunctions ask', () => {
},
);

it.each([false, true])(
'publishes document aliases once before consuming files (override=%s)',
(override) => {
const file: ExtendedFile = {
file_id: 'server-document',
temp_file_id: 'upload-document',
filename: 'report.pdf',
type: 'application/pdf',
filepath: '/uploads/report.pdf',
llmDeliveryPath: 'text',
size: 100,
progress: 1,
};
const { result, setFiles, setSubmission } = renderAsk([], 'new', {
files: new Map([['map-document', file]]),
});
const write = jest.spyOn(Storage.prototype, 'setItem');
setFiles.mockImplementation(() => {
expect(isPasteSubmitted('map-document')).toBe(true);
expect(isPasteSubmitted(file.file_id)).toBe(true);
expect(isPasteSubmitted(file.temp_file_id)).toBe(true);
});

act(() =>
result.current.ask(
{ text: 'Read this document' },
override ? { overrideFiles: [file] } : undefined,
),
);

expect(
write.mock.calls.filter(([key]) => key === 'librechat-submitted-paste-file-ids'),
).toHaveLength(1);
expect(setFiles).toHaveBeenCalledTimes(override ? 0 : 1);
const submission = setSubmission.mock.calls.at(-1)?.[0] as TSubmission;
expect(submission.userMessage.files?.[0]).toMatchObject({
file_id: file.file_id,
llmDeliveryPath: 'text',
});
expect(isPasteSubmitted(file.file_id)).toBe(true);
expect(isPasteSubmitted(file.temp_file_id)).toBe(true);
},
);

it('reads an approval-mode selection made immediately before send', () => {
mockGetLatestConversation.mockReturnValue({
...conversation('conversation-1'),
Expand Down
41 changes: 21 additions & 20 deletions client/src/hooks/Chat/useChatFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,10 +679,11 @@ export default function useChatFunctions({
currentMsg.files = [...submissionFiles];
/** Queued override files were consumed just like composer files, so mark their identities
* as submitted before later draft cleanup can classify the restored paste as unsent. */
submissionFiles.forEach((file) => {
markPasteSubmitted(file.file_id);
markPasteSubmitted(file.temp_file_id);
});
const submittedFileIds: (string | undefined)[] = [];
for (const file of submissionFiles) {
submittedFileIds.push(file.file_id, file.temp_file_id);
}
markPasteSubmitted(...submittedFileIds);
// Caller-supplied overrideFiles were consumed elsewhere (queued
// during-run messages take theirs out of the composer at queue time);
// clearing here would eat attachments staged for the user's NEXT send.
Expand All @@ -696,22 +697,22 @@ export default function useChatFunctions({
// `overrideFiles` (even empty) is authoritative for the submission:
// auto-drained queued messages must never vacuum up attachments the
// user has staged in the composer for their NEXT message.
currentMsg.files = Array.from(files.values()).map((file) => ({
file_id: file.file_id,
filepath: file.filepath,
filename: file.filename,
type: file.type ?? '', // Ensure type is not undefined
llmDeliveryPath: file.llmDeliveryPath,
height: file.height,
width: file.width,
}));
/** The draft keeps a paste's provenance after the map is emptied, so discarding later has
* to be able to tell what this message already took with it. */
files.forEach((file, key) => {
markPasteSubmitted(key);
markPasteSubmitted(file.file_id);
markPasteSubmitted(file.temp_file_id);
});
const submittedFileIds: (string | undefined)[] = [];
currentMsg.files = [];
for (const [key, file] of files) {
currentMsg.files.push({
file_id: file.file_id,
filepath: file.filepath,
filename: file.filename,
type: file.type ?? '',
llmDeliveryPath: file.llmDeliveryPath,
height: file.height,
width: file.width,
});
submittedFileIds.push(key, file.file_id, file.temp_file_id);
}
// Publish every alias before clearing the composer or its draft.
markPasteSubmitted(...submittedFileIds);
setFiles(new Map());
setFilesToDelete({});
}
Expand Down
8 changes: 6 additions & 2 deletions client/src/hooks/Files/useSetFilesToDelete.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { useCallback } from 'react';
import { LocalStorageKeys } from 'librechat-data-provider';

export default function useSetFilesToDelete() {
const setFilesToDelete = (files: Record<string, unknown>) =>
localStorage.setItem(LocalStorageKeys.FILES_TO_DELETE, JSON.stringify(files));
const setFilesToDelete = useCallback(
(files: Record<string, unknown>) =>
localStorage.setItem(LocalStorageKeys.FILES_TO_DELETE, JSON.stringify(files)),
[],
);
return setFilesToDelete;
}
Loading
Loading