mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
refactor: keep only staged context in the tray
This commit is contained in:
parent
c4963a742d
commit
0616aa4bf2
3 changed files with 11 additions and 343 deletions
|
|
@ -587,16 +587,11 @@ const ChatForm = memo(function ChatForm({
|
|||
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
||||
<Tray
|
||||
items={composerItems}
|
||||
conversationId={conversationId}
|
||||
conversation={conversation}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
setFilesLoading={setFilesLoading}
|
||||
isRTL={isRTL}
|
||||
steering={steering}
|
||||
steeringEnabled={steering.enabled}
|
||||
onEditToComposer={editToComposer}
|
||||
onRestoreToComposer={restoreReclaimedSteer}
|
||||
/>
|
||||
{endpoint && (
|
||||
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,11 @@
|
|||
import { memo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Zap, Clock, Pencil, Trash2, RotateCcw, TextQuote, WandSparkles } from 'lucide-react';
|
||||
import type { TMessage, TConversation } from 'librechat-data-provider';
|
||||
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
|
||||
import { TextQuote, WandSparkles } from 'lucide-react';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { ComposerItem, ComposerItemKind } from '~/hooks/Input/useComposerItems';
|
||||
import type { PendingSteer, QueuedMessage } from '~/store/families';
|
||||
import type { RestoreToComposer } from '../InFlightSteers';
|
||||
import type { ExtendedFile, FileSetter } from '~/common';
|
||||
import type { MenuEntry } from '../SteerMenu';
|
||||
import { RowMenu, useDefaultToggleEntry, ICON_BTN_CLASS, PRIMARY_BTN_CLASS } from '../SteerMenu';
|
||||
import { useFileHandlingNoChatContext } from '~/hooks';
|
||||
import { useFileHandlingNoChatContext, useLocalize } from '~/hooks';
|
||||
import Chip, { type ChipTone } from './Chip';
|
||||
import FileRow from '../Files/FileRow';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
const KIND_ICON: Record<ComposerItemKind, JSX.Element> = {
|
||||
quote: <TextQuote className="h-4 w-4" aria-hidden="true" />,
|
||||
|
|
@ -41,215 +33,28 @@ function ItemChip({ item }: { item: ComposerItem }) {
|
|||
);
|
||||
}
|
||||
|
||||
function QueuedChip({
|
||||
message,
|
||||
steering,
|
||||
conversationId,
|
||||
onEditToComposer,
|
||||
onRestoreToComposer,
|
||||
}: {
|
||||
message: QueuedMessage;
|
||||
steering: SteeringControls;
|
||||
conversationId: string;
|
||||
onEditToComposer: (
|
||||
text: string,
|
||||
files?: TMessage['files'],
|
||||
context?: QueuedMessageContext,
|
||||
) => void;
|
||||
onRestoreToComposer: RestoreToComposer;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const toggleEntry = useDefaultToggleEntry(steering);
|
||||
const canSteerNow = steering.duringRunActive && steering.canSteer;
|
||||
const showPrimary = canSteerNow || !steering.duringRunActive;
|
||||
|
||||
const entries: MenuEntry[] = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: localize('com_ui_edit_message'),
|
||||
icon: <Pencil className="h-4 w-4" aria-hidden="true" />,
|
||||
onClick: () => {
|
||||
steering.removeQueued(message.id);
|
||||
onEditToComposer(message.text, message.files, {
|
||||
quotes: message.quotes,
|
||||
manualSkills: message.manualSkills,
|
||||
});
|
||||
},
|
||||
},
|
||||
toggleEntry,
|
||||
];
|
||||
|
||||
return (
|
||||
<Chip
|
||||
block
|
||||
tone="queued"
|
||||
label={message.text}
|
||||
icon={<Clock className="h-4 w-4" aria-hidden="true" />}
|
||||
data-testid="queued-message-row"
|
||||
trailing={
|
||||
<>
|
||||
{showPrimary && (
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BTN_CLASS}
|
||||
onClick={() => steering.sendQueuedNow(message)}
|
||||
>
|
||||
{canSteerNow ? (
|
||||
<>
|
||||
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
|
||||
{localize('com_ui_steer')}
|
||||
</>
|
||||
) : (
|
||||
localize('com_ui_send_now')
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_remove_queued')}
|
||||
onClick={() => {
|
||||
/* Same safety net as the in-flight cancel: return the words to the
|
||||
* composer when it is free (the gated restore refuses rather than
|
||||
* clobber a draft), then remove either way. */
|
||||
onRestoreToComposer(
|
||||
message.text,
|
||||
message.files,
|
||||
{ quotes: message.quotes, manualSkills: message.manualSkills },
|
||||
conversationId,
|
||||
);
|
||||
steering.removeQueued(message.id);
|
||||
}}
|
||||
className={ICON_BTN_CLASS}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<RowMenu label={localize('com_ui_more_options')} entries={entries} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedSteerChip({
|
||||
steer,
|
||||
steering,
|
||||
onEditToComposer,
|
||||
}: {
|
||||
steer: PendingSteer;
|
||||
steering: SteeringControls;
|
||||
onEditToComposer: (
|
||||
text: string,
|
||||
files?: TMessage['files'],
|
||||
context?: QueuedMessageContext,
|
||||
) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const toggleEntry = useDefaultToggleEntry(steering);
|
||||
|
||||
const entries: MenuEntry[] = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: localize('com_ui_edit_message'),
|
||||
icon: <Pencil className="h-4 w-4" aria-hidden="true" />,
|
||||
onClick: () => {
|
||||
steering.removeSteer(steer.steerId);
|
||||
onEditToComposer(steer.text, steer.files, {
|
||||
quotes: steer.quotes,
|
||||
manualSkills: steer.manualSkills,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'queue',
|
||||
label: localize('com_ui_convert_to_queue'),
|
||||
icon: <Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />,
|
||||
onClick: () =>
|
||||
steering.convertSteerToQueue(steer.steerId, steer.text, steer.files, {
|
||||
quotes: steer.quotes,
|
||||
manualSkills: steer.manualSkills,
|
||||
}),
|
||||
},
|
||||
toggleEntry,
|
||||
];
|
||||
|
||||
return (
|
||||
<Chip
|
||||
block
|
||||
tone="failed"
|
||||
label={steer.text}
|
||||
icon={<Zap className="h-4 w-4" aria-hidden="true" />}
|
||||
data-testid="steer-message-row"
|
||||
onRemove={() => steering.removeSteer(steer.steerId)}
|
||||
removeLabel={localize('com_ui_remove_queued')}
|
||||
trailing={
|
||||
<>
|
||||
<span className="shrink-0 text-xs text-red-500">{localize('com_ui_steer_failed')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BTN_CLASS}
|
||||
onClick={() =>
|
||||
steering.retrySteer(steer.steerId, steer.text, steer.files, {
|
||||
quotes: steer.quotes,
|
||||
manualSkills: steer.manualSkills,
|
||||
})
|
||||
}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_steer_retry')}
|
||||
</button>
|
||||
<RowMenu label={localize('com_ui_more_options')} entries={entries} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface TrayProps {
|
||||
items: ComposerItem[];
|
||||
conversationId: string;
|
||||
conversation: TConversation | null;
|
||||
/** Staged uploads, rendered by `FileRow` with their own delete semantics. */
|
||||
files: Map<string, ExtendedFile>;
|
||||
setFiles: FileSetter;
|
||||
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isRTL: boolean;
|
||||
steering: SteeringControls;
|
||||
steeringEnabled: boolean;
|
||||
onEditToComposer: (
|
||||
text: string,
|
||||
files?: TMessage['files'],
|
||||
context?: QueuedMessageContext,
|
||||
) => void;
|
||||
onRestoreToComposer: RestoreToComposer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer's single staged-context region. Replaces the four stacked rows
|
||||
* (uploads, quotes, manual skills, queued/failed steers) with one region: file
|
||||
* cards first, then the pills, then the queued and failed rows — which carry
|
||||
* their own primary action and overflow menu — full width beneath them.
|
||||
* The composer's staged-context region: file cards first, then the quote and
|
||||
* manual-skill pills. Queued and failed-steer rows live elsewhere (the Queue
|
||||
* rail and the thread, respectively).
|
||||
*
|
||||
* Files keep `FileRow` rather than being flattened into pills: the card is
|
||||
* where an image preview opens full size, where upload progress and the storage
|
||||
* source are drawn, and where aborting a part-done upload differs from deleting
|
||||
* a finished one.
|
||||
*/
|
||||
function Tray({
|
||||
items,
|
||||
conversationId,
|
||||
conversation,
|
||||
files,
|
||||
setFiles,
|
||||
setFilesLoading,
|
||||
isRTL,
|
||||
steering,
|
||||
steeringEnabled,
|
||||
onEditToComposer,
|
||||
onRestoreToComposer,
|
||||
}: TrayProps) {
|
||||
function Tray({ items, conversation, files, setFiles, setFilesLoading, isRTL }: TrayProps) {
|
||||
const localize = useLocalize();
|
||||
const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId));
|
||||
const queued = useRecoilValue(store.queuedMessagesByConvoId(steering.queueKey));
|
||||
const { abortUpload } = useFileHandlingNoChatContext(undefined, {
|
||||
files,
|
||||
setFiles,
|
||||
|
|
@ -257,15 +62,7 @@ function Tray({
|
|||
conversation,
|
||||
});
|
||||
|
||||
const failedSteers = steeringEnabled ? steers.filter((steer) => steer.status === 'failed') : [];
|
||||
const queuedMessages = steeringEnabled ? queued : [];
|
||||
|
||||
if (
|
||||
items.length === 0 &&
|
||||
files.size === 0 &&
|
||||
failedSteers.length === 0 &&
|
||||
queuedMessages.length === 0
|
||||
) {
|
||||
if (items.length === 0 && files.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -290,24 +87,6 @@ function Tray({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{failedSteers.map((steer) => (
|
||||
<FailedSteerChip
|
||||
key={steer.steerId}
|
||||
steer={steer}
|
||||
steering={steering}
|
||||
onEditToComposer={onEditToComposer}
|
||||
/>
|
||||
))}
|
||||
{queuedMessages.map((message) => (
|
||||
<QueuedChip
|
||||
key={message.id}
|
||||
message={message}
|
||||
steering={steering}
|
||||
conversationId={conversationId}
|
||||
onEditToComposer={onEditToComposer}
|
||||
onRestoreToComposer={onRestoreToComposer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import type { PendingSteer, QueuedMessage } from '~/store/families';
|
||||
import type { ComposerItem } from '~/hooks/Input/useComposerItems';
|
||||
import type { SteeringControls } from '~/hooks/Chat/useSteering';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import store from '~/store';
|
||||
import Tray from '../Tray';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
|
|
@ -21,31 +18,6 @@ jest.mock('../../Files/FileRow', () => ({
|
|||
files.size > 0 ? <div data-testid="file-row">{files.size}</div> : null,
|
||||
}));
|
||||
|
||||
const mockRemoveSteer = jest.fn();
|
||||
const mockRemoveQueued = jest.fn();
|
||||
const mockRetrySteer = jest.fn();
|
||||
|
||||
jest.mock('../../SteerMenu', () => ({
|
||||
RowMenu: () => <div data-testid="row-menu" />,
|
||||
useDefaultToggleEntry: () => ({ key: 'toggle', label: 'toggle', onClick: jest.fn() }),
|
||||
ICON_BTN_CLASS: '',
|
||||
PRIMARY_BTN_CLASS: '',
|
||||
}));
|
||||
|
||||
const CONVO_ID = 'convo-1';
|
||||
|
||||
const steering = {
|
||||
enabled: true,
|
||||
queueKey: CONVO_ID,
|
||||
duringRunActive: false,
|
||||
canSteer: false,
|
||||
removeSteer: mockRemoveSteer,
|
||||
removeQueued: mockRemoveQueued,
|
||||
retrySteer: mockRetrySteer,
|
||||
sendQueuedNow: jest.fn(),
|
||||
convertSteerToQueue: jest.fn(),
|
||||
} as unknown as SteeringControls;
|
||||
|
||||
const item = (overrides: Partial<ComposerItem> = {}): ComposerItem => ({
|
||||
id: 'quote:0',
|
||||
kind: 'quote',
|
||||
|
|
@ -55,32 +27,21 @@ const item = (overrides: Partial<ComposerItem> = {}): ComposerItem => ({
|
|||
...overrides,
|
||||
});
|
||||
|
||||
function renderTray(
|
||||
items: ComposerItem[],
|
||||
seed?: (snapshot: MutableSnapshotLike) => void,
|
||||
files: Map<string, ExtendedFile> = new Map(),
|
||||
) {
|
||||
function renderTray(items: ComposerItem[], files: Map<string, ExtendedFile> = new Map()) {
|
||||
return render(
|
||||
<RecoilRoot initializeState={seed}>
|
||||
<RecoilRoot>
|
||||
<Tray
|
||||
items={items}
|
||||
conversationId={CONVO_ID}
|
||||
conversation={null}
|
||||
files={files}
|
||||
setFiles={jest.fn()}
|
||||
setFilesLoading={jest.fn()}
|
||||
isRTL={false}
|
||||
steering={steering}
|
||||
steeringEnabled
|
||||
onEditToComposer={jest.fn()}
|
||||
onRestoreToComposer={jest.fn()}
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
}
|
||||
|
||||
type MutableSnapshotLike = { set: (atom: never, value: never) => void };
|
||||
|
||||
describe('Tray', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
|
|
@ -95,7 +56,6 @@ describe('Tray', () => {
|
|||
item({ id: 'quote:0', kind: 'quote', label: 'the second paragraph' }),
|
||||
item({ id: 'skill:a', kind: 'skill', label: 'code-review' }),
|
||||
],
|
||||
undefined,
|
||||
new Map([['f1', {} as ExtendedFile]]),
|
||||
);
|
||||
|
||||
|
|
@ -123,74 +83,8 @@ describe('Tray', () => {
|
|||
});
|
||||
|
||||
it('opens for staged files even with nothing else in it', () => {
|
||||
renderTray([], undefined, new Map([['f1', {} as ExtendedFile]]));
|
||||
renderTray([], new Map([['f1', {} as ExtendedFile]]));
|
||||
expect(screen.getByTestId('composer-tray')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('file-row')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps queued messages as full-width rows with their own actions', () => {
|
||||
const queued: QueuedMessage[] = [
|
||||
{ id: 'q1', text: 'follow up on this', files: [], quotes: [], manualSkills: [] },
|
||||
] as unknown as QueuedMessage[];
|
||||
|
||||
renderTray([], ({ set }: MutableSnapshotLike) =>
|
||||
set(store.queuedMessagesByConvoId(CONVO_ID) as never, queued as never),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('queued-message-row')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText('com_ui_remove_queued'));
|
||||
expect(mockRemoveQueued).toHaveBeenCalledWith('q1');
|
||||
});
|
||||
|
||||
it('surfaces failed steers with a retry affordance', () => {
|
||||
const steers: PendingSteer[] = [
|
||||
{
|
||||
steerId: 's1',
|
||||
text: 'actually use zod',
|
||||
status: 'failed',
|
||||
files: [],
|
||||
quotes: [],
|
||||
manualSkills: [],
|
||||
},
|
||||
] as unknown as PendingSteer[];
|
||||
|
||||
renderTray([], ({ set }: MutableSnapshotLike) =>
|
||||
set(store.pendingSteersByConvoId(CONVO_ID) as never, steers as never),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('steer-message-row')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_steer_failed')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('com_ui_steer_retry'));
|
||||
expect(mockRetrySteer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores steer state when steering is disabled for the endpoint', () => {
|
||||
const queued = [
|
||||
{ id: 'q1', text: 'follow up', files: [], quotes: [], manualSkills: [] },
|
||||
] as unknown as QueuedMessage[];
|
||||
|
||||
const { container } = render(
|
||||
<RecoilRoot
|
||||
initializeState={({ set }: MutableSnapshotLike) =>
|
||||
set(store.queuedMessagesByConvoId(CONVO_ID) as never, queued as never)
|
||||
}
|
||||
>
|
||||
<Tray
|
||||
items={[]}
|
||||
conversationId={CONVO_ID}
|
||||
conversation={null}
|
||||
files={new Map()}
|
||||
setFiles={jest.fn()}
|
||||
setFilesLoading={jest.fn()}
|
||||
isRTL={false}
|
||||
steering={steering}
|
||||
steeringEnabled={false}
|
||||
onEditToComposer={jest.fn()}
|
||||
onRestoreToComposer={jest.fn()}
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue